brewpage-mcp 1.20.0 → 1.22.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
@@ -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.20.0
41
+ npx -y brewpage-mcp@1.22.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.20.0"]
66
+ "args": ["-y", "brewpage-mcp@1.22.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.20.0
89
+ claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.22.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.20.0"]
113
+ "args": ["-y", "brewpage-mcp@1.22.0"]
114
114
  }
115
115
  }
116
116
  }
@@ -124,7 +124,7 @@ Open the Cline MCP settings panel and add:
124
124
  {
125
125
  "brewpage": {
126
126
  "command": "npx",
127
- "args": ["-y", "brewpage-mcp@1.20.0"]
127
+ "args": ["-y", "brewpage-mcp@1.22.0"]
128
128
  }
129
129
  }
130
130
  ```
@@ -156,14 +156,14 @@ Thirty-two tools are available. Creation returns an owner token; keep it to modi
156
156
  | `bind_token` | Add an owner token to your profile (admin token) |
157
157
  | `mint_token` | Mint a profile token, shown once (admin token) |
158
158
  | `set_domain` | Connect a domain you own to an HTML page or site; returns the DNS records to create |
159
- | `list_domains` | List the profile's custom domains with status and DNS records (admin token) |
160
- | `check_domain` | Check a custom domain's DNS now; status `pending_dns` -> `verifying` -> `active` |
159
+ | `list_domains` | Read one custom domain's status and DNS records without a check, or list the profile's custom domains (admin token) |
160
+ | `check_domain` | Run a DNS check once the records exist; status `pending_dns` -> `verifying` -> `active` |
161
161
  | `remove_domain` | Remove a custom domain and free its slot; the BrewPage link keeps working |
162
162
  | `get_page_data` | Read what visitors saved on your page: the settings and usage, or one list's entries |
163
163
  | `set_page_data` | Write author entries (for example today's menu) without republishing the page |
164
164
  | `configure_page_data` | Change who can read a list and its limits; only what you name changes |
165
165
  | `delete_page_data` | Delete entries by id, or every entry in a time range |
166
- | `get_guide` | Read the short Page data guide before writing a page that saves data |
166
+ | `get_guide` | Read a short guide: Page data before writing a page that saves data, or the custom-domain steps |
167
167
  | `search_gallery` | Browse public content or list owned resources |
168
168
  | `get_stats` | Read platform-wide statistics |
169
169
  | `get_limits` | Read the current upload limits (`GET /api/limits`) |
@@ -510,37 +510,48 @@ Parameters: `label` (optional), `scope` (`publish` default | `admin`), `expiresI
510
510
 
511
511
  ### Custom domains
512
512
 
513
- Show one HTML page (format html, no password) or one site on a domain you own, such as `www.example.com` or `example.com`. Free, 5 per profile. The owner token must be in a free BrewPage profile: sign in at https://account.brewpage.app and add the token there, or with `bind_token`. The BrewPage link keeps working.
513
+ Show one HTML page (format html, no password) or one site on a domain you own, such as `www.example.com` or `example.com`. Free, up to 5 per profile. A profile token works at once; an owner token must first be added to a free BrewPage profile: sign in at https://account.brewpage.app/tokens, press Add existing token and paste it (or `bind_token` with an admin profile token). Any registrar or DNS host works; if you are buying a domain, Cloudflare and Porkbun are the quickest to set up. The BrewPage link keeps working.
514
514
 
515
- How it goes: `set_domain` returns the DNS records, the domain owner creates exactly those records at their DNS provider, then `check_domain` reports the status: `pending_dns` (records not seen yet), `verifying` (DNS found, HTTPS being issued), `active` (live). HTTPS is issued automatically. The records come from the result, never from memory: a subdomain takes a TXT record at `_brewpage.<host>` plus a CNAME to `domains.brewpage.app`; a root domain takes the TXT record plus the A record shown, or the optional ALIAS row.
515
+ How it goes:
516
516
 
517
- `publish_html` and `publish_site` accept an optional `domain` to connect it while publishing. The publication is always created; the result prints a `CUSTOM DOMAIN` block with the records, or says the domain was not set next to the notice that explains why.
517
+ 1. `set_domain` returns the DNS records. They come from the result, never from memory: a subdomain takes a TXT record at `_brewpage.<host>` plus a CNAME to `domains.brewpage.app`; a root domain takes the TXT record plus the A record shown, or the optional ALIAS row.
518
+ 2. The domain owner creates exactly those records at their DNS provider: MX (mail) records stay, an A, AAAA or CNAME record at the same name is replaced, and at Cloudflare Proxy status is set to DNS only.
519
+ 3. After the records exist, `check_domain` once.
520
+ 4. `list_domains` with the same `type`, `namespace` and `id` reads the status without a new check until it is `active`. BrewPage checks on its own (the first full check about 15 minutes after adding); the domain is usually live within 30 minutes and HTTPS is issued automatically. Another `check_domain` is needed only after a DNS change.
521
+
522
+ Statuses: `pending_dns` (records not seen yet), `verifying` (DNS found, HTTPS being issued), `active` (live), `lost` (the records broke after going live; the domain is still served until `graceEndsAt`: restore the records and check again). The `Problem:` line of a result names what blocks progress and its fix; a refusal names its next step. The same steps are in `get_guide(topic="custom-domain")` for the assistant.
523
+
524
+ `publish_html` and `publish_site` accept an optional `domain` to connect it while publishing. The publication is always created; the result prints a `CUSTOM DOMAIN` block with the records, or says the domain was not set, with the next step, below the notice that explains why.
518
525
 
519
526
  ### `set_domain`
520
527
 
521
528
  Connect a domain to an HTML page or site (`PUT /api/publications/{html|site}/{ns}/{id}/domain`). A hostname already in the profile moves to this publication. A page on a BrewPage path address moves to its own subdomain, and its BrewPage link keeps working.
522
529
 
523
- Parameters: `type` (`html` | `site`), `namespace`, `id`, `hostname` (e.g. `www.example.com` or `example.com`), `ownerToken` (optional; the publication's owner token added to a profile, or a profile token).
530
+ Parameters: `type` (`html` | `site`), `namespace`, `id`, `hostname` (a bare hostname such as `www.example.com` or `example.com`, no scheme or path), `ownerToken` (optional; a profile token, or the publication's owner token added to a profile).
524
531
 
525
532
  ```
526
533
  set_domain(type="html", namespace="public", id="aBcDeFgHiJ", hostname="www.example.com")
527
534
  ```
528
535
 
529
- Output: `Custom domain connected.`, then `Domain:`, `Status:`, `Problem:` (what is still missing), `Serving:`, `Shows:`, the `records[]` rows verbatim as JSON (`type`, `name`, `host`, `value`, `state`) and the next step. `host` is the name DNS panels ask for.
536
+ Output: `Custom domain added. It is not live until the DNS records below exist.`, then `Domain:`, `Status:`, `Problem:` (what is still missing, with the fix), `Serving:`, `Shows:`, the `records[]` rows verbatim as JSON (`type`, `name`, `host`, `value`, `state`) and the next step. `host` is the name DNS panels ask for.
530
537
 
531
538
  ---
532
539
 
533
540
  ### `list_domains`
534
541
 
535
- List the custom domains of a profile (`GET /api/domains`): how many are used and allowed, and for each one its status, DNS records and the publication it shows.
542
+ Read custom domains without running a DNS check. With `type`, `namespace` and `id` (`GET /api/publications/{html|site}/{ns}/{id}/domain`; a profile token or an owner token added to the profile) or with `hostname` (`GET /api/domains/{hostname}`; admin profile token): the status, problem, DNS records and next step of that one custom domain. Without an address (`GET /api/domains`; admin profile token): every custom domain of the profile, plus how many are used and allowed.
543
+
544
+ Parameters: optional `type` + `namespace` + `id`, or `hostname`; `ownerToken` (optional, default `BREWPAGE_OWNER_TOKEN`, local server only).
536
545
 
537
- Parameters: `ownerToken` (admin token of the profile; optional, default `BREWPAGE_OWNER_TOKEN`, local server only).
546
+ ```
547
+ list_domains(type="html", namespace="public", id="aBcDeFgHiJ")
548
+ ```
538
549
 
539
550
  ---
540
551
 
541
552
  ### `check_domain`
542
553
 
543
- Check DNS now and return the updated status and records. By publication: `type`, `namespace`, `id` with an owner token in the profile (`POST /api/publications/{html|site}/{ns}/{id}/domain/check`). By hostname: `hostname` with an admin token of the profile (`POST /api/domains/{hostname}/check`). Checks are spaced: a repeat too soon answers 429 and the result prints `Retry-After`.
554
+ Run a DNS check now and return the updated status and records. One check after the records exist is enough: BrewPage keeps checking on its own, and `list_domains` reads the status without a check. By publication: `type`, `namespace`, `id` with a profile token or an owner token added to the profile (`POST /api/publications/{html|site}/{ns}/{id}/domain/check`). By hostname: `hostname` with an admin profile token (`POST /api/domains/{hostname}/check`). A repeat within seconds answers 429 and the result prints `Retry-After`.
544
555
 
545
556
  Parameters: either `type` + `namespace` + `id`, or `hostname`; `ownerToken` (optional).
546
557
 
@@ -584,9 +595,9 @@ The four page-data tools below take `namespace` + `id` + `ownerToken`, or `manag
584
595
 
585
596
  ### `get_guide`
586
597
 
587
- Read a short reference text. Topic `page-data`: how a page saves what visitors enter, with the script calls and the `state` settings. Meant to be read before writing such a page. No API call.
598
+ Read a short reference text. Topic `page-data`: how a page saves what visitors enter, with the script calls and the `state` settings. Meant to be read before writing such a page. Topic `custom-domain`: the steps that put a page or site on a domain you own, with the statuses and the fix for each problem. No API call.
588
599
 
589
- Parameters: `topic` (`page-data`).
600
+ Parameters: `topic` (`page-data` | `custom-domain`).
590
601
 
591
602
  ---
592
603
 
@@ -693,7 +704,8 @@ get_limits()
693
704
  | "List my published pages" / "What did I publish?" | `search_gallery` | Set `mine=true` + `ownerToken` |
694
705
  | "Browse the public BrewPage gallery" | `search_gallery` | Optional `q`, `sort=date\|views` |
695
706
  | "Put this page on my own domain" | `set_domain` | Owner token in a free profile; returns the DNS records to create |
696
- | "I added the DNS records, is my domain live?" | `check_domain` | Status `pending_dns` -> `verifying` -> `active` |
707
+ | "I added the DNS records" | `check_domain` | Once; status `pending_dns` -> `verifying` -> `active` |
708
+ | "Is my domain live yet?" | `list_domains` | With `type`, `namespace`, `id`: reads the status without a new check |
697
709
  | "Which domains do I have?" | `list_domains` | Admin token of the profile |
698
710
  | "Disconnect my domain" | `remove_domain` | The BrewPage link keeps working |
699
711
  | "Make a page with a guestbook / form / poll that saves answers" | `get_guide`, then `publish_html` | Publish with `state`; give the user the page link and the management link |
@@ -731,12 +743,14 @@ Every publish response includes an **owner token** -- the only credential that a
731
743
  - Memory: run with `--max-old-space-size=192` and a 512 MiB container limit.
732
744
  - Never reads `BREWPAGE_OWNER_TOKEN`; refuses to start when it is set. Pass `ownerToken` per call.
733
745
  - 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.
746
+ - `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.
734
747
 
735
748
  | Variable | Default | Description |
736
749
  |----------|---------|-------------|
737
750
  | `BREWPAGE_TRUSTED_PROXY_CIDRS` | required | Comma list of reverse-proxy CIDRs or IPs |
738
751
  | `BREWPAGE_SHARED_EGRESS_CIDRS` | empty | Comma list of platform egress CIDRs (one IP, many users): global limits only, no per-IP caps |
739
752
  | `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 |
753
+ | `BREWPAGE_METRICS_CIDRS` | empty | Comma list of CIDRs or IPs allowed to read `GET /metrics`; empty = `/metrics` answers 404 |
740
754
  | `BREWPAGE_BACKEND_URL` | `http://brewpage-backend-mcp:8080` | API base URL (internal address) |
741
755
  | `BREWPAGE_PUBLIC_ORIGIN` | `https://brewpage.app` | Origin for returned links and forwarded proto/host |
742
756
  | `BREWPAGE_MCP_PORT` | `8787` | Listen port |
@@ -755,6 +769,14 @@ Every publish response includes an **owner token** -- the only credential that a
755
769
 
756
770
  ## Changelog
757
771
 
772
+ ## 1.22.0 -- 2026-11-12
773
+
774
+ - Custom domains: the texts now lead to a connected domain. A refusal names its next step (an owner token in no profile: sign in at https://account.brewpage.app/tokens, press Add existing token, paste it, repeat); results give the DNS records step (mail records stay, a same-name A, AAAA or CNAME is replaced, Cloudflare Proxy status DNS only), one `check_domain`, then status reads until `active`; the `Problem:` line names the fix; status `lost` is described. `list_domains` takes an optional `type` + `namespace` + `id` or `hostname` and reads that one custom domain without a DNS check; without them it lists the profile as before. `get_guide` adds the topic `custom-domain`. Thirty-two tools. Contract 2.28.0. `User-Agent: brewpage-mcp/1.22.0`.
775
+
776
+ ## 1.21.0 -- 2026-11-12
777
+
778
+ - 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`.
779
+
758
780
  ## 1.20.0 -- 2026-11-12
759
781
 
760
782
  - 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`.
@@ -0,0 +1,4 @@
1
+ /** Custom-domain statuses: the "Statuses (`status`):" table under "## Custom Domains" of llms-full.txt. */
2
+ export declare const DOMAIN_STATUSES: Record<string, string>;
3
+ /** Custom-domain problems and fixes: the "Problems (`problem`) and the fix:" table of the same section; rows with a <placeholder> key left out. */
4
+ export declare const DOMAIN_PROBLEMS: Record<string, string>;
@@ -0,0 +1,26 @@
1
+ // Generated by scripts/sync-guide.mjs from brewpage-app frontend/src (llms-full.txt); do not hand-edit.
2
+ /** Custom-domain statuses: the "Statuses (`status`):" table under "## Custom Domains" of llms-full.txt. */
3
+ export const DOMAIN_STATUSES = {
4
+ "pending_dns": "DNS records not seen yet: create them; `problem` names the missing one",
5
+ "verifying": "DNS records found, HTTPS being issued: wait",
6
+ "active": "live at `url` (`serving: true`)",
7
+ "lost": "DNS records broke after going live; still served until `graceEndsAt`: restore them, then check",
8
+ };
9
+ /** Custom-domain problems and fixes: the "Problems (`problem`) and the fix:" table of the same section; rows with a <placeholder> key left out. */
10
+ export const DOMAIN_PROBLEMS = {
11
+ "DOMAIN_NOT_REGISTERED": "the domain does not exist yet: register it with a domain registrar, then create the records",
12
+ "TXT_MISSING": "create the TXT record of `records[]`",
13
+ "TXT_WRONG": "a `bp-verify=` TXT record has another value: replace it with the one in `records[]`",
14
+ "ROUTING_MISSING": "create the CNAME record (apex: the A or ALIAS record)",
15
+ "ROUTING_WRONG": "the name points elsewhere: replace its A or CNAME record (Cloudflare: DNS only)",
16
+ "FOREIGN_AAAA": "delete the AAAA record listed in `records[]`",
17
+ "CAA_BLOCKS": "add the CAA record listed in `records[]`",
18
+ "DOMAIN_TAKEN": "this domain is already connected: use another hostname or write to info@brewpage.app",
19
+ "QUEUE_FULL": "DNS records are right, verification waits its turn: nothing to do",
20
+ "CERT_PENDING": "HTTPS certificate on its way: nothing to do",
21
+ "CERT_FAILED": "the certificate could not be issued: check the DNS records, then check again",
22
+ "DNS_LOST": "DNS records stayed broken past `graceEndsAt`, so the domain was disconnected: restore them, then check again",
23
+ "CHECK_PAUSED": "automatic checks paused after a long failure: fix DNS, then check again (resumes them)",
24
+ "RESOLVER_UNAVAILABLE": "DNS did not answer: retried automatically",
25
+ "PUBLICATION_GONE": "the publication is gone: connect the custom domain to another one",
26
+ };
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.20.0";
3
+ export declare const USER_AGENT = "brewpage-mcp/1.22.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
@@ -1,8 +1,9 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from "zod";
3
+ import { DOMAIN_PROBLEMS, DOMAIN_STATUSES } from "./guides/custom-domain.js";
3
4
  import { PAGE_DATA_CALLS, PAGE_DATA_GUIDE } from "./guides/page-data.js";
4
5
  export const DEFAULT_BASE_URL = "https://brewpage.app";
5
- export const USER_AGENT = "brewpage-mcp/1.20.0";
6
+ export const USER_AGENT = "brewpage-mcp/1.22.0";
6
7
  export const OWNER_TOKEN_ENV = "BREWPAGE_OWNER_TOKEN";
7
8
  /** Server instructions of the handshake: one paragraph per line; the first stands alone within 512 chars. */
8
9
  export const SERVER_INSTRUCTIONS = [
@@ -10,6 +11,7 @@ export const SERVER_INSTRUCTIONS = [
10
11
  "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
12
  "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
13
  "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).",
14
+ "A custom domain, only when the user asks for their own domain: set_domain, the user creates the returned DNS records at their DNS provider, check_domain once, then list_domains reads the status until it is active. Steps and fixes: get_guide topic custom-domain.",
13
15
  ].join("\n");
14
16
  const READ_ONLY = { readOnlyHint: true };
15
17
  const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true };
@@ -198,32 +200,67 @@ function configuredListLine(name, named, before, after) {
198
200
  return after.mode === "shared" ? `${name} - ${listRuleText(after)} (was private)`
199
201
  : `${name} - ${listRuleText(after)} (was shared; it cannot become shared again while it holds visitors' entries)`;
200
202
  }
201
- const DOMAIN_PROFILE_TEXT = "Works when the owner token is in a free BrewPage profile (sign-in at https://account.brewpage.app); free, 5 domains per profile.";
202
- const DOMAIN_ADDRESS_TEXT = "Addressed by publication (type, namespace, id; an owner token in the profile) or by hostname (an admin token of the profile).";
203
- const DOMAIN_OWNER_TOKEN = `Owner token in a BrewPage profile: the publication's own token added to the profile, or a profile token. Default: ${ENV_DEFAULT}.`;
204
- const DOMAIN_EITHER_TOKEN = `By publication: an owner token in the profile. By hostname: an admin token of the profile. Default: ${ENV_DEFAULT}.`;
205
- const DOMAIN_ADDRESS_ERROR = "Provide either `hostname` (admin token of the profile) or `type`, `namespace` and `id` (an owner token in the profile).";
206
- const DOMAIN_ARGUMENT = "Optional domain the user owns (www.example.com or example.com) to connect to the new publication. " +
207
- "Works when the owner token is in a free BrewPage profile, without a password. The publication is always created; " +
208
- "the result lists the DNS records for the domain owner to create, or says why the domain was not set.";
209
- /** Plain words for `DomainItem.problem`; other codes print bare. */
210
- const DOMAIN_PROBLEMS = {
211
- TXT_MISSING: "the TXT record is not visible yet",
212
- TXT_WRONG: "the TXT record has another value",
213
- ROUTING_MISSING: "the CNAME or A record is not visible yet",
214
- ROUTING_WRONG: "the CNAME or A record points elsewhere",
215
- FOREIGN_AAAA: "an AAAA record points elsewhere: delete it",
216
- CAA_BLOCKS: "a CAA record stops the HTTPS certificate: add the CAA record listed",
217
- DOMAIN_TAKEN: "this hostname is not available",
218
- QUEUE_FULL: "DNS is right; the next step is queued and retried automatically",
219
- CERT_PENDING: "the HTTPS certificate is on its way",
220
- CERT_FAILED: "the HTTPS certificate was not issued: check DNS, then check again",
221
- DNS_LOST: "the records stayed broken: restore them, then check again",
222
- CHECK_PAUSED: "automatic checks are paused; a check resumes them",
223
- RESOLVER_UNAVAILABLE: "DNS did not answer; retried automatically",
224
- PUBLICATION_GONE: "the publication is gone: set the domain on another one",
203
+ const DOMAIN_TOKENS_PAGE = "https://account.brewpage.app/tokens";
204
+ const DOMAIN_PROFILE_TEXT = `Free with a BrewPage profile, up to 5 custom domains: a profile token works at once, an owner token after it is added to a profile (sign-in at ${DOMAIN_TOKENS_PAGE}, "Add existing token").`;
205
+ const DOMAIN_PROVIDER_TEXT = "Any registrar or DNS host works; if the user is buying a domain, Cloudflare and Porkbun are the quickest to set up.";
206
+ const DOMAIN_ADDRESS_TEXT = "Addressed by publication (type, namespace, id; a profile token or an owner token added to the profile) or by hostname (an admin profile token).";
207
+ const DOMAIN_OWNER_TOKEN = `A profile token, or the publication's owner token added to a BrewPage profile. Default: ${ENV_DEFAULT}.`;
208
+ const DOMAIN_EITHER_TOKEN = `By publication: a profile token or an owner token added to the profile. By hostname: an admin profile token. Default: ${ENV_DEFAULT}.`;
209
+ const DOMAIN_LIST_TOKEN = `Whole list or by hostname: an admin profile token. By publication: a profile token or an owner token added to the profile. Default: ${ENV_DEFAULT}.`;
210
+ const DOMAIN_ADDRESS_ERROR = "Provide either `hostname` (an admin profile token) or `type`, `namespace` and `id` (a profile token or an owner token added to the profile).";
211
+ const DOMAIN_HOSTNAME = "Custom domain as a bare hostname, e.g. www.example.com; instead of type, namespace and id";
212
+ const DOMAIN_ARGUMENT = "Optional custom domain for the new publication: a bare hostname the user owns (www.example.com or example.com, no scheme or path). " +
213
+ "Needs a profile token or an owner token added to a free BrewPage profile, and no password. The publication is always created; " +
214
+ "the result lists the DNS records for the domain owner to create, or says why the custom domain was not set.";
215
+ /** Status sentence of the check_domain description; names every key of DOMAIN_STATUSES. */
216
+ const DOMAIN_STATUS_TEXT = "Status: pending_dns (DNS records not seen yet), verifying (DNS records found, HTTPS being issued), active (live), " +
217
+ "lost (DNS records broke after going live; still served until `graceEndsAt`; restored records and one more check bring it back). `problem` names the fix.";
218
+ /** Next step per `reason` of 409 NOT_ELIGIBLE and per `NOT_ELIGIBLE_<reason>` problem. */
219
+ const DOMAIN_REASONS = {
220
+ OWNERSHIP: `add its owner token to the profile (bind_token with an admin profile token, or "Add existing token" at ${DOMAIN_TOKENS_PAGE})`,
221
+ OLD: "it is an old publication: publish it again and connect the custom domain to the new one",
222
+ RETIRED: "its address was retired: connect the custom domain to a live publication",
223
+ TYPE: "only HTML pages and sites can",
224
+ FORMAT: "the page is not format html: publish it as format html",
225
+ PASSWORD: "remove the password (management link, or POST /api/{html|sites}/{ns}/{id}/password {\"password\":null})",
226
+ EXPIRED: "it has expired: connect the custom domain to another publication",
227
+ MODE: "it left its own subdomain: change its hosting back to subdomain (update_hosting deliveryMode=subdomain)",
225
228
  };
226
- const DOMAIN_NEXT_CREATE = "Next: ask the user to add exactly these records at their DNS provider and change nothing else (keep MX/email records); `host` is the name DNS panels ask for. Then call check_domain. BrewPage also checks on its own and issues HTTPS automatically.";
229
+ const DOMAIN_READ_STATUS = "read the status with list_domains (same type, namespace and id, or hostname)";
230
+ const DOMAIN_NEXT_CREATE = "Next: give the user exactly these DNS records to create at their DNS provider (`host` is the name DNS panels ask for). " +
231
+ "MX/mail records stay; an A, AAAA or CNAME record at the same name is replaced. When the user says the records exist, call check_domain once, then " +
232
+ DOMAIN_READ_STATUS + " until it is active: BrewPage checks on its own (first full check about 15 minutes after adding), " +
233
+ "usually live within 30 minutes, HTTPS is automatic. Call check_domain again only after a DNS change.";
234
+ const DOMAIN_NEXT_VERIFYING = "Next: DNS records are found and HTTPS is being issued. To follow it, " + DOMAIN_READ_STATUS +
235
+ " until it is active; call check_domain again only if the Problem line says so or DNS changed.";
236
+ const DOMAIN_NEXT_LOST = "Next: DNS records broke after going live. Ask the user to restore exactly these DNS records at their DNS provider, then call check_domain once.";
237
+ const DOMAIN_SUSPENDED_TEXT = "BrewPage suspended this custom domain: the user writes to info@brewpage.app; repeating a call does not help.";
238
+ /** Custom-domain guide: the flow in tool words, then the status and problem tables generated from llms-full.txt. */
239
+ const CUSTOM_DOMAIN_GUIDE = [
240
+ "BrewPage custom domain: a hostname the user owns (example.com, docs.example.com) shows one HTML page of format html or one site, without a password. Only when the user asks for their own domain.",
241
+ `Who: ${DOMAIN_PROFILE_TEXT}`,
242
+ " 403 DOMAIN_NEEDS_ACCOUNT = the owner token is in no profile: give the user the owner token (it is theirs) to add there, then repeat the call. With an admin profile token bind_token adds it instead.",
243
+ `Domain: ${DOMAIN_PROVIDER_TEXT}`,
244
+ "Steps:",
245
+ "1) set_domain (type, namespace, id, hostname), or `domain` on publish_html / publish_site. The hostname is bare: www.example.com or example.com, no scheme or path.",
246
+ "2) Give the user exactly the DNS records of the result to create at their DNS provider (`host` is the name DNS panels ask for); never values from memory.",
247
+ " MX/mail records stay; an A, AAAA or CNAME record at the same name is replaced. At Cloudflare set Proxy status to DNS only.",
248
+ "3) When the user says the records exist: check_domain once.",
249
+ "4) Read the status with list_domains (type, namespace, id) until it is active. BrewPage checks on its own (first full check about 15 minutes after adding);",
250
+ " usually live within 30 minutes, HTTPS is issued automatically. check_domain again only after a DNS change; on 429 wait Retry-After and read instead.",
251
+ "Statuses (`Status:` line of a result):",
252
+ ...Object.entries(DOMAIN_STATUSES).map(([status, text]) => ` ${status} - ${text}`),
253
+ "Problems (`Problem:` line) and the fix:",
254
+ ...Object.entries(DOMAIN_PROBLEMS).map(([problem, fix]) => ` ${problem} - ${fix}`),
255
+ " NOT_ELIGIBLE_<reason> - the publication cannot take a custom domain now; the line names the fix",
256
+ "A refusal names its next step. The BrewPage link keeps working throughout. set_domain with the same hostname on another publication of the profile moves it; remove_domain removes it.",
257
+ ].join("\n") + "\n";
258
+ /** `Problem:` value: the code plus its fix (generated table, or the reason of a `NOT_ELIGIBLE_<reason>` code); other codes print bare. */
259
+ function domainProblemText(problem) {
260
+ const reason = problem.startsWith("NOT_ELIGIBLE_") ? DOMAIN_REASONS[problem.slice("NOT_ELIGIBLE_".length)] : undefined;
261
+ const fix = reason ? `the publication cannot take a custom domain now: ${reason}` : DOMAIN_PROBLEMS[problem];
262
+ return fix ? `${problem} (${fix})` : problem;
263
+ }
227
264
  /** One custom domain (`DomainItem`): status, `records[]` verbatim and the next step. */
228
265
  function domainLines(item) {
229
266
  const { hostname, displayHostname, problem, url } = item;
@@ -233,7 +270,7 @@ function domainLines(item) {
233
270
  `Status: ${item.status}`,
234
271
  ];
235
272
  if (typeof problem === "string" && problem)
236
- lines.push(`Problem: ${problem}${DOMAIN_PROBLEMS[problem] ? ` (${DOMAIN_PROBLEMS[problem]})` : ""}`);
273
+ lines.push(`Problem: ${domainProblemText(problem)}`);
237
274
  lines.push(`Serving: ${item.serving === true}`);
238
275
  if (item.suspended === true)
239
276
  lines.push(`suspended: true`);
@@ -247,7 +284,7 @@ function domainLines(item) {
247
284
  lines.push(`The publication moved to its own BrewPage subdomain (a custom domain needs it); its BrewPage link keeps working.`);
248
285
  if (typeof item.detached === "string" && item.detached)
249
286
  lines.push(`Earlier domain ${item.detached} stays in the profile, not attached.`);
250
- for (const [field, label] of [["checkedAt", "Checked"], ["nextCheckAt", "Next automatic check"], ["graceEndsAt", "Shown until"]]) {
287
+ for (const [field, label] of [["checkedAt", "Checked"], ["nextCheckAt", "Next automatic check"], ["graceEndsAt", "Served until (graceEndsAt)"]]) {
251
288
  if (typeof item[field] === "string")
252
289
  lines.push(`${label}: ${item[field]}`);
253
290
  }
@@ -259,44 +296,83 @@ function domainLines(item) {
259
296
  if (item.provider === "cloudflare")
260
297
  lines.push(`Cloudflare: Proxy status "DNS only" on the CNAME or A record.`);
261
298
  if (item.suspended === true)
262
- return lines;
299
+ return [...lines, DOMAIN_SUSPENDED_TEXT];
300
+ if (problem === "DOMAIN_NOT_REGISTERED")
301
+ lines.push(DOMAIN_PROVIDER_TEXT);
302
+ // The DNS provider is unknown until a check names it, so the Cloudflare rule travels with the step.
263
303
  if (item.status === "pending_dns")
264
- lines.push(DOMAIN_NEXT_CREATE);
304
+ lines.push(DOMAIN_NEXT_CREATE + (typeof item.provider === "string" && item.provider ? "" : " At Cloudflare set Proxy status to DNS only."));
265
305
  if (item.status === "verifying")
266
- lines.push(`Next: DNS is found and HTTPS is being issued. Call check_domain again shortly.`);
306
+ lines.push(DOMAIN_NEXT_VERIFYING);
267
307
  if (item.status === "active")
268
- lines.push(`Live: the domain shows the publication over HTTPS. The BrewPage link keeps working.`);
308
+ lines.push(`Live: the custom domain shows the publication over HTTPS. The BrewPage link keeps working.`);
269
309
  if (item.status === "lost")
270
- lines.push(`Next: DNS changed after going live. Ask the user to restore exactly these records at their DNS provider, then call check_domain.`);
310
+ lines.push(DOMAIN_NEXT_LOST);
271
311
  return lines;
272
312
  }
273
- /** Create with `domain`: the returned `customDomain`, else a line saying it was not set (the reason is a `domain-*` notice). */
274
- function publishDomainLines(data, requested) {
313
+ /** 403 `DOMAIN_NEEDS_ACCOUNT` and its create notice: the profile step in the user's hands, then `then`. */
314
+ function domainNeedsProfile(fromEnv, then) {
315
+ return `The owner token is in no BrewPage profile. Give the user the owner token (it is theirs${fromEnv ? `; it is the ${OWNER_TOKEN_ENV} value in this server's settings` : ""}) ` +
316
+ `and ask them to sign in at ${DOMAIN_TOKENS_PAGE} (free), press "Add existing token" and paste it; then ${then}. With an admin profile token bind_token adds it instead.`;
317
+ }
318
+ const DOMAIN_LIMIT_NEXT = "remove_domain frees one; a hostname already in the profile moves here with set_domain.";
319
+ /** 409 `DOMAIN_PROFILE_AMBIGUOUS` and its create notice; `then` = the call to make with the profile token. */
320
+ function domainAmbiguous(then) {
321
+ return `The token is in several profiles: ${then} with a profile token of the profile that should hold the custom domain (minted at ${DOMAIN_TOKENS_PAGE}).`;
322
+ }
323
+ /** Next step per `domain-*` notice of a create; other codes point at the notice text. */
324
+ function publishDomainNext(code, fromEnv) {
325
+ if (code === "domain-needs-account")
326
+ return ` ${domainNeedsProfile(fromEnv, "call set_domain for this publication")}`;
327
+ if (code === "domain-limit-reached")
328
+ return ` Next: ${DOMAIN_LIMIT_NEXT}`;
329
+ if (code === "domain-profile-ambiguous")
330
+ return ` ${domainAmbiguous("call set_domain")}`;
331
+ if (code === "domain-not-eligible" || code === "domain-invalid")
332
+ return " The domain notice above says what to fix; then set_domain connects it.";
333
+ if (code === "domain-account-restricted")
334
+ return " The domain notice above says why.";
335
+ return " The domain notice above says why. set_domain connects it later.";
336
+ }
337
+ /** Create with `domain`: the returned `customDomain`, else a line saying it was not set plus the next step of its `domain-*` notice. */
338
+ function publishDomainLines(data, requested, fromEnv = false) {
275
339
  const item = data.customDomain;
276
340
  if (item && typeof item === "object")
277
341
  return [``, `CUSTOM DOMAIN`, ...domainLines(item)];
278
342
  if (!requested)
279
343
  return [];
280
- const reason = notices(data).some((n) => n.code.startsWith("domain-")) ? " The domain notice above says why." : "";
281
- return [``, `CUSTOM DOMAIN: ${requested} was not set; the publication is live at its BrewPage link.${reason} set_domain connects it later.`];
344
+ const notice = notices(data).find((n) => n.code.startsWith("domain-"));
345
+ return [``, `CUSTOM DOMAIN: ${requested} was not set; the publication is live at its BrewPage link.` +
346
+ (notice ? publishDomainNext(notice.code, fromEnv) : " set_domain connects it later.")];
282
347
  }
283
- /** Next step after a custom-domain refusal; other codes keep the server answer alone. */
284
- function domainHints(data, byHostname) {
285
- const { code, limit, used } = (data ?? {});
286
- if (code === "DOMAIN_NEEDS_ACCOUNT") {
287
- return ["Custom domains need a free BrewPage account: sign in at https://account.brewpage.app and add this token to your profile (bind_token), then retry."];
348
+ /** Next step after a custom-domain refusal; `address` = how the call named the domain. Other codes keep the server answer alone. */
349
+ function domainHints(data, address, fromEnv = false) {
350
+ const { code, limit, used, reason } = (data ?? {});
351
+ switch (code) {
352
+ case "DOMAIN_NEEDS_ACCOUNT": return [domainNeedsProfile(fromEnv, "repeat this call")];
353
+ case "DOMAIN_LIMIT": return typeof limit === "number" && limit > 0
354
+ ? [`The profile uses ${typeof used === "number" ? used : limit} of ${limit} custom domains: ${DOMAIN_LIMIT_NEXT}`] : [];
355
+ case "ADMIN_TOKEN_REQUIRED": return ["This call needs an admin profile token." + (address === "publication" ? ""
356
+ : " With a publish-scope profile token or an owner token added to the profile, pass type, namespace and id" + (address === "hostname" ? " instead of hostname." : "."))];
357
+ case "DOMAIN_CHECK_TOO_SOON": return [`Checked a moment ago: do not call check_domain again now. BrewPage keeps checking on its own; after Retry-After ${DOMAIN_READ_STATUS}.`];
358
+ case "DOMAIN_NOT_FOUND": return ["No custom domain there: list_domains shows the profile's custom domains; set_domain connects one."];
359
+ case "INVALID_HOSTNAME": return ["Pass one bare hostname such as www.example.com or example.com (no scheme, path or port); the message says what to fix."];
360
+ case "DOMAIN_NOT_ALLOWED": return ["brewpage.app addresses and some domains cannot be used: ask the user for a domain they own."];
361
+ case "NOT_ELIGIBLE": return typeof reason === "string" && DOMAIN_REASONS[reason] ? [`This publication cannot take a custom domain: ${DOMAIN_REASONS[reason]}.`] : [];
362
+ case "DOMAIN_PROFILE_AMBIGUOUS": return [domainAmbiguous("repeat")];
363
+ case "OWNER_TOKEN_REVOKED":
364
+ case "OWNER_TOKEN_EXPIRED":
365
+ return [`The token ${code === "OWNER_TOKEN_REVOKED" ? "was revoked" : "expired"}: repeat with another profile token or the publication's owner token.`];
366
+ case "ACCOUNT_RESTRICTED":
367
+ case "ACCOUNT_BLOCKED":
368
+ return [`This profile cannot add custom domains${code === "ACCOUNT_RESTRICTED" ? " now" : ""}: tell the user; repeating the call does not help.`];
369
+ case "DOMAIN_SUSPENDED": return [DOMAIN_SUSPENDED_TEXT];
370
+ case "PUBLICATION_UNAVAILABLE": return ["The publication is missing or expired (outside public a wrong token answers the same): check type, namespace, id and the token."];
371
+ case "FEATURE_DISABLED":
372
+ case "DOMAIN_INTAKE_PAUSED":
373
+ case "HOSTING_CREATION_PAUSED": return ["Temporary: tell the user and try again later."];
374
+ default: return [];
288
375
  }
289
- if (code === "DOMAIN_LIMIT" && typeof limit === "number" && limit > 0) {
290
- return [`The profile uses ${typeof used === "number" ? used : limit} of ${limit} custom domains: remove_domain frees one; a hostname already in the profile moves here with set_domain.`];
291
- }
292
- if (code === "ADMIN_TOKEN_REQUIRED") {
293
- return ["Custom domains need an admin token from a free BrewPage profile." + (byHostname ? " With an owner token of the publication, pass type, namespace and id instead of hostname." : "")];
294
- }
295
- if (code === "DOMAIN_CHECK_TOO_SOON")
296
- return ["Checked a moment ago: wait, then call check_domain again."];
297
- if (code === "DOMAIN_NOT_FOUND")
298
- return ["No custom domain there: list_domains shows the profile's domains; set_domain connects one."];
299
- return [];
300
376
  }
301
377
  /** `/api/<segments>` with each segment encoded; empty, `.` and `..` refused (URL parsing would resolve them away). */
302
378
  export function apiPath(...segments) {
@@ -518,7 +594,7 @@ export function createServer(options = {}) {
518
594
  const apiFetch = options.fetch ?? fetch;
519
595
  const server = new McpServer({
520
596
  name: "brewpage-mcp",
521
- version: "1.20.0",
597
+ version: "1.22.0",
522
598
  }, { instructions: SERVER_INSTRUCTIONS });
523
599
  /** Caller argument first, then the configured default; `fromEnv` marks the configured one. */
524
600
  function ownerTokenFor(argument) {
@@ -584,7 +660,7 @@ export function createServer(options = {}) {
584
660
  lines.push(`Expires: ${data.expiresAt}`);
585
661
  lines.push(...hostingResponseLines(data));
586
662
  lines.push(...pageDataPublishLines(data, sentState));
587
- lines.push(...publishDomainLines(data, requestedDomain));
663
+ lines.push(...publishDomainLines(data, requestedDomain, sentOwnerToken !== undefined && sentOwnerToken === defaultOwnerToken));
588
664
  lines.push(...ownerTokenLines(data, "update or delete this resource later", sentOwnerToken));
589
665
  lines.push(...ownerLinkLines(data));
590
666
  const ns = (data.namespace ?? effectiveNamespace) || "";
@@ -599,7 +675,7 @@ export function createServer(options = {}) {
599
675
  "Format order: `format`, then a known filename extension, else the server detects it. Returns its primary share link and owner token. Optional password, TTL, filename, top toolbar (showTopBar). " +
600
676
  "A `public` page without deliveryMode may get its own subdomain if it needs browser features a BrewPage link lacks; " +
601
677
  "the result then has deliveryModeReason and a delivery notice. Markdown raw HTML is allowlisted: scripts, styles, event handlers, forms, embeds removed. " +
602
- "Optional `domain` connects a domain the user owns to an HTML-format page. " +
678
+ "Optional `domain` connects a custom domain the user owns to an HTML-format page. " +
603
679
  MANAGE_URL_TEXT + " " + SECRETS + " " + BADGE_TEXT + " " + PUBLISH_STATE_TEXT,
604
680
  inputSchema: {
605
681
  content: z.string().describe("HTML, Markdown, code or JSON to publish"),
@@ -863,7 +939,7 @@ export function createServer(options = {}) {
863
939
  ...toolMeta("Publish site", ADDITIVE),
864
940
  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. " +
865
941
  "Static only: HTML, CSS, JS (incl. built React/Vue bundles) run in the browser; server code (PHP etc.) never runs. " + SITE_LIMITS_TEXT +
866
- " Optional `domain` connects a domain the user owns. " + MANAGE_URL_TEXT + " " + SECRETS + " " + BADGE_TEXT + " " + PUBLISH_STATE_TEXT,
942
+ " Optional `domain` connects a custom domain the user owns. " + MANAGE_URL_TEXT + " " + SECRETS + " " + BADGE_TEXT + " " + PUBLISH_STATE_TEXT,
867
943
  inputSchema: {
868
944
  deliveryMode: DELIVERY_MODE_SCHEMA,
869
945
  entryContent: z
@@ -969,7 +1045,7 @@ export function createServer(options = {}) {
969
1045
  lines.push(`Expires: ${site.expiresAt}`);
970
1046
  lines.push(...hostingResponseLines(site));
971
1047
  lines.push(...pageDataPublishLines(site, sentState?.value));
972
- lines.push(...publishDomainLines(site, domain || undefined));
1048
+ lines.push(...publishDomainLines(site, domain || undefined, owner !== undefined && owner === defaultOwnerToken));
973
1049
  lines.push(...ownerTokenLines(site, "view info or delete this site", owner));
974
1050
  lines.push(...ownerLinkLines(site));
975
1051
  const ns = (site.namespace || effectiveNamespace) || "";
@@ -1636,15 +1712,18 @@ export function createServer(options = {}) {
1636
1712
  }
1637
1713
  server.registerTool("set_domain", {
1638
1714
  ...toolMeta("Connect custom domain", ADDITIVE),
1639
- description: "Connect a domain the user owns (www.example.com or example.com) to one HTML page or site. " + DOMAIN_PROFILE_TEXT +
1640
- " Returns the DNS records for the domain owner to create at their DNS provider: TXT for ownership plus CNAME, or A/ALIAS for a root domain. " +
1641
- "A DNS check after that moves the status pending_dns -> verifying -> active; HTTPS is issued automatically. " +
1642
- "HTML pages of format html and sites, without a password. A hostname already in the profile moves to this publication. The BrewPage link keeps working.",
1715
+ description: "Connect a custom domain (a hostname the user owns) to one HTML page of format html or one site, without a password. " + DOMAIN_PROFILE_TEXT +
1716
+ " Returns the DNS records for the domain owner to create at their DNS provider exactly as returned: MX/mail records stay, " +
1717
+ "an A, AAAA or CNAME record at the same name is replaced, at Cloudflare Proxy status is DNS only. " +
1718
+ "One DNS check after the records exist is enough: BrewPage keeps checking on its own (first full check about 15 minutes after adding); " +
1719
+ "usually live within 30 minutes, HTTPS is automatic. Status: pending_dns -> verifying -> active (lost when the DNS records break later); `problem` names the fix. " +
1720
+ "A hostname already in the profile moves to this publication; the BrewPage link keeps working. " + DOMAIN_PROVIDER_TEXT +
1721
+ " Steps and fixes: guide topic `custom-domain`.",
1643
1722
  inputSchema: {
1644
1723
  type: z.enum(["html", "site"]).describe("Publication type"),
1645
1724
  namespace: z.string().describe("Publication namespace"),
1646
1725
  id: z.string().describe("Publication ID"),
1647
- hostname: z.string().describe("Domain or subdomain the user owns, e.g. www.example.com or example.com"),
1726
+ hostname: z.string().describe("Bare hostname the user owns: www.example.com or example.com, no scheme or path"),
1648
1727
  ownerToken: z.string().optional().describe(DOMAIN_OWNER_TOKEN),
1649
1728
  },
1650
1729
  }, async ({ type, namespace, id, hostname, ownerToken }) => {
@@ -1654,23 +1733,40 @@ export function createServer(options = {}) {
1654
1733
  const path = apiPath("publications", type, namespace, id, "domain");
1655
1734
  const { ok, status, data, headers: answerHeaders } = await apiRequest("PUT", path, { hostname }, { "X-Owner-Token": auth.token });
1656
1735
  if (!ok)
1657
- return ownerFailure("set domain", status, data, auth.fromEnv, answerHeaders, domainHints(data, false));
1658
- const lead = status === 201 ? "Custom domain connected." : "Custom domain set: it was already in the profile (moved here or unchanged).";
1736
+ return ownerFailure("set domain", status, data, auth.fromEnv, answerHeaders, domainHints(data, "publication", auth.fromEnv));
1737
+ const lead = status === 201 ? "Custom domain added. It is not live until the DNS records below exist."
1738
+ : "Custom domain set: it was already in the profile (moved here or unchanged).";
1659
1739
  return textResult([lead, ``, ...domainLines(data)].join("\n"));
1660
1740
  });
1661
1741
  server.registerTool("list_domains", {
1662
1742
  ...toolMeta("List custom domains", READ_ONLY),
1663
- description: "List the custom domains of a BrewPage profile: status, DNS records and the publication each one shows, plus how many the profile holds and may hold. Needs an admin token of the profile.",
1743
+ description: "Read custom domains without running a DNS check: status, `problem`, DNS records and the publication shown. " +
1744
+ "No address: every custom domain of the profile plus how many it holds and may hold. With type, namespace and id, or with hostname: that one custom domain.",
1664
1745
  inputSchema: {
1665
- ownerToken: z.string().optional().describe(`Admin token of the profile. Default: ${ENV_DEFAULT}.`),
1746
+ type: z.enum(["html", "site"]).optional().describe("Publication type; with namespace and id, instead of hostname"),
1747
+ namespace: z.string().optional().describe("Publication namespace"),
1748
+ id: z.string().optional().describe("Publication ID"),
1749
+ hostname: z.string().optional().describe(DOMAIN_HOSTNAME),
1750
+ ownerToken: z.string().optional().describe(DOMAIN_LIST_TOKEN),
1666
1751
  },
1667
- }, async ({ ownerToken }) => {
1752
+ }, async ({ type, namespace, id, hostname, ownerToken }) => {
1668
1753
  const auth = ownerTokenFor(ownerToken);
1669
1754
  if (!auth.token)
1670
1755
  return textResult(NO_OWNER_TOKEN, true);
1756
+ // An empty string reads as an omitted argument: the list stays reachable for a client that fills every field.
1757
+ const [kind, ns, row, host] = [type, namespace, id, hostname].map((part) => part || undefined);
1758
+ if ([kind, ns, row, host].some((part) => part !== undefined)) {
1759
+ const path = domainPath(kind, ns, row, host);
1760
+ if (path === null)
1761
+ return textResult(DOMAIN_ADDRESS_ERROR, true);
1762
+ const { ok, status, data, headers: answerHeaders } = await apiRequest("GET", path, undefined, { "X-Owner-Token": auth.token });
1763
+ if (!ok)
1764
+ return ownerFailure("read domain", status, data, auth.fromEnv, answerHeaders, domainHints(data, host !== undefined ? "hostname" : "publication", auth.fromEnv));
1765
+ return textResult(domainLines(data).join("\n"));
1766
+ }
1671
1767
  const { ok, status, data, headers: answerHeaders } = await apiRequest("GET", "/api/domains", undefined, { "X-Owner-Token": auth.token });
1672
1768
  if (!ok)
1673
- return ownerFailure("list domains", status, data, auth.fromEnv, answerHeaders, domainHints(data, false));
1769
+ return ownerFailure("list domains", status, data, auth.fromEnv, answerHeaders, domainHints(data, "list", auth.fromEnv));
1674
1770
  const list = data;
1675
1771
  const items = Array.isArray(list.items) ? list.items : [];
1676
1772
  const lines = [`Custom domains: ${list.used} of ${list.limit} used`];
@@ -1682,14 +1778,15 @@ export function createServer(options = {}) {
1682
1778
  });
1683
1779
  server.registerTool("check_domain", {
1684
1780
  ...toolMeta("Check custom domain DNS", ADDITIVE),
1685
- description: "Check the DNS of a custom domain now and return its status and records. Status: pending_dns (records not seen yet), " +
1686
- "verifying (DNS found, HTTPS being issued), active (live). " + DOMAIN_ADDRESS_TEXT +
1687
- " Checks are spaced: a repeat too soon answers 429 with Retry-After.",
1781
+ description: "Run a DNS check of a custom domain now and return its status, `problem` and DNS records. " +
1782
+ "One check after the DNS records exist is enough: BrewPage keeps checking on its own (`nextCheckAt`) and reading the status needs no check; " +
1783
+ "another check fits only after a DNS change. " + DOMAIN_STATUS_TEXT + " " + DOMAIN_ADDRESS_TEXT +
1784
+ " A repeat within seconds answers 429 with Retry-After.",
1688
1785
  inputSchema: {
1689
1786
  type: z.enum(["html", "site"]).optional().describe("Publication type; with namespace and id, instead of hostname"),
1690
1787
  namespace: z.string().optional().describe("Publication namespace"),
1691
1788
  id: z.string().optional().describe("Publication ID"),
1692
- hostname: z.string().optional().describe("Custom domain, e.g. www.example.com; instead of type, namespace and id"),
1789
+ hostname: z.string().optional().describe(DOMAIN_HOSTNAME),
1693
1790
  ownerToken: z.string().optional().describe(DOMAIN_EITHER_TOKEN),
1694
1791
  },
1695
1792
  }, async ({ type, namespace, id, hostname, ownerToken }) => {
@@ -1701,18 +1798,18 @@ export function createServer(options = {}) {
1701
1798
  return textResult(DOMAIN_ADDRESS_ERROR, true);
1702
1799
  const { ok, status, data, headers: answerHeaders } = await apiRequest("POST", path, undefined, { "X-Owner-Token": auth.token });
1703
1800
  if (!ok)
1704
- return ownerFailure("check domain", status, data, auth.fromEnv, answerHeaders, domainHints(data, hostname !== undefined));
1801
+ return ownerFailure("check domain", status, data, auth.fromEnv, answerHeaders, domainHints(data, hostname !== undefined ? "hostname" : "publication", auth.fromEnv));
1705
1802
  return textResult([`DNS checked.`, ``, ...domainLines(data)].join("\n"));
1706
1803
  });
1707
1804
  server.registerTool("remove_domain", {
1708
1805
  ...toolMeta("Remove custom domain", DESTRUCTIVE),
1709
1806
  description: "Remove a custom domain from the profile: it stops showing the publication and its slot is free at once. The BrewPage link keeps working. " +
1710
- DOMAIN_ADDRESS_TEXT + " DNS records at the provider stay until the domain owner deletes them.",
1807
+ DOMAIN_ADDRESS_TEXT + " DNS records at the DNS provider stay until the domain owner deletes them.",
1711
1808
  inputSchema: {
1712
1809
  type: z.enum(["html", "site"]).optional().describe("Publication type; with namespace and id, instead of hostname"),
1713
1810
  namespace: z.string().optional().describe("Publication namespace"),
1714
1811
  id: z.string().optional().describe("Publication ID"),
1715
- hostname: z.string().optional().describe("Custom domain, e.g. www.example.com; instead of type, namespace and id"),
1812
+ hostname: z.string().optional().describe(DOMAIN_HOSTNAME),
1716
1813
  ownerToken: z.string().optional().describe(DOMAIN_EITHER_TOKEN),
1717
1814
  },
1718
1815
  }, async ({ type, namespace, id, hostname, ownerToken }) => {
@@ -1724,7 +1821,7 @@ export function createServer(options = {}) {
1724
1821
  return textResult(DOMAIN_ADDRESS_ERROR, true);
1725
1822
  const { ok, status, data, headers: answerHeaders } = await apiRequest("DELETE", path, undefined, { "X-Owner-Token": auth.token });
1726
1823
  if (!ok)
1727
- return ownerFailure("remove domain", status, data, auth.fromEnv, answerHeaders, domainHints(data, hostname !== undefined));
1824
+ return ownerFailure("remove domain", status, data, auth.fromEnv, answerHeaders, domainHints(data, hostname !== undefined ? "hostname" : "publication", auth.fromEnv));
1728
1825
  const what = hostname !== undefined ? `Custom domain removed: ${hostname}` : `No custom domain on ${type} ${namespace}/${id} now`;
1729
1826
  return textResult(`${what} · slot free · BrewPage link unchanged\nIts DNS records can be deleted at the DNS provider.`);
1730
1827
  });
@@ -1933,10 +2030,11 @@ export function createServer(options = {}) {
1933
2030
  ...toolMeta("Read BrewPage guide", READ_ONLY),
1934
2031
  description: "Short reference texts for building on BrewPage. Topic `page-data`: how a published page saves what visitors enter " +
1935
2032
  "(forms, guestbook, votes, leaderboard, game progress) with the script `https://brewpage.app/js/state.js` and the `state` config; " +
1936
- "meant to be read before writing such a page.",
2033
+ "meant to be read before writing such a page. Topic `custom-domain`: the steps that put a page or site on a domain the user owns " +
2034
+ "(profile, DNS records, one check, statuses, problems and fixes).",
1937
2035
  inputSchema: {
1938
- topic: z.enum(["page-data"]).describe("Guide topic"),
2036
+ topic: z.enum(["page-data", "custom-domain"]).describe("Guide topic"),
1939
2037
  },
1940
- }, async () => textResult(PAGE_DATA_GUIDE + PAGE_DATA_GUIDE_TAIL));
2038
+ }, async ({ topic }) => textResult(topic === "custom-domain" ? CUSTOM_DOMAIN_GUIDE : PAGE_DATA_GUIDE + PAGE_DATA_GUIDE_TAIL));
1941
2039
  return server;
1942
2040
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brewpage-mcp",
3
- "version": "1.20.0",
3
+ "version": "1.22.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",