brewpage-mcp 1.17.0 → 1.18.1

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 twenty-one 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 twenty-five 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
 
@@ -17,14 +17,14 @@ Choosing a host? See how BrewPage compares with GitHub Pages, Netlify Drop, Tiin
17
17
  ## Quick Start
18
18
 
19
19
  ```bash
20
- npx -y brewpage-mcp@1.17.0
20
+ npx -y brewpage-mcp@1.18.1
21
21
  ```
22
22
 
23
23
  Requires Node.js 20 or newer. This starts the stdio server for an MCP client.
24
24
 
25
25
  ### Remote (no install)
26
26
 
27
- Add `https://brewpage.app/mcp` as a custom connector / remote MCP server by URL in Claude.ai, ChatGPT or any Streamable HTTP client. No auth; same 21 tools.
27
+ Add `https://brewpage.app/mcp` as a custom connector / remote MCP server by URL in Claude.ai, ChatGPT or any Streamable HTTP client. No auth; same 25 tools.
28
28
 
29
29
  Claude Code: `claude mcp add --transport http brewpage https://brewpage.app/mcp`, or in `.mcp.json`:
30
30
 
@@ -50,7 +50,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
50
50
  "mcpServers": {
51
51
  "brewpage": {
52
52
  "command": "npx",
53
- "args": ["-y", "brewpage-mcp@1.17.0"]
53
+ "args": ["-y", "brewpage-mcp@1.18.1"]
54
54
  }
55
55
  }
56
56
  }
@@ -61,7 +61,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
61
61
  Register a project-scoped stdio server:
62
62
 
63
63
  ```bash
64
- claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.17.0
64
+ claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.18.1
65
65
  ```
66
66
 
67
67
  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).
@@ -75,7 +75,7 @@ Open **Settings > MCP** and add:
75
75
  "mcpServers": {
76
76
  "brewpage": {
77
77
  "command": "npx",
78
- "args": ["-y", "brewpage-mcp@1.17.0"]
78
+ "args": ["-y", "brewpage-mcp@1.18.1"]
79
79
  }
80
80
  }
81
81
  }
@@ -89,7 +89,7 @@ Open the Cline MCP settings panel and add:
89
89
  {
90
90
  "brewpage": {
91
91
  "command": "npx",
92
- "args": ["-y", "brewpage-mcp@1.17.0"]
92
+ "args": ["-y", "brewpage-mcp@1.18.1"]
93
93
  }
94
94
  }
95
95
  ```
@@ -97,13 +97,13 @@ Open the Cline MCP settings panel and add:
97
97
  ### Global Install
98
98
 
99
99
  ```bash
100
- npm install -g brewpage-mcp@1.17.0
100
+ npm install -g brewpage-mcp@1.18.1
101
101
  brewpage-mcp
102
102
  ```
103
103
 
104
104
  ## Tools
105
105
 
106
- Twenty-one 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.
106
+ Twenty-five 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.
107
107
 
108
108
  | Tool | Purpose |
109
109
  |------|---------|
@@ -125,6 +125,10 @@ Twenty-one tools are available. Creation returns an owner token; keep it to modi
125
125
  | `set_title` | Rename an HTML page or site; the title stays on republish; link unchanged |
126
126
  | `bind_token` | Add an owner token to your profile (admin token) |
127
127
  | `mint_token` | Mint a profile token, shown once (admin token) |
128
+ | `set_domain` | Connect a domain you own to an HTML page or site; returns the DNS records to create |
129
+ | `list_domains` | List the profile's custom domains with status and DNS records (admin token) |
130
+ | `check_domain` | Check a custom domain's DNS now; status `pending_dns` -> `verifying` -> `active` |
131
+ | `remove_domain` | Remove a custom domain and free its slot; the BrewPage link keeps working |
128
132
  | `search_gallery` | Browse public content or list owned resources |
129
133
  | `get_stats` | Read platform-wide statistics |
130
134
  | `get_limits` | Read the current upload limits (`GET /api/limits`) |
@@ -159,7 +163,7 @@ update_hosting(type="html", namespace="public", id="aBcDeFgHiJ", ownerToken="tok
159
163
 
160
164
  Publish HTML, Markdown, code or JSON content to BrewPage; with `format` omitted the server detects it. Returns a public URL and owner token. Markdown raw HTML is rendered through an allowlist: scripts, styles, event handlers, forms, frames/embeds and `meta`/`base`/`link` are removed; links allow `http`, `https`, `mailto`, `tel` and relative URLs, images also `data:`; a lone unknown tag such as `<author>` shows as text.
161
165
 
162
- Parameters: `content` (string), `format` (optional; omit to let the server detect HTML, Markdown, code or JSON; `html` keeps code/JSON as HTML; also `markdown`/`md` or a code language), `namespace` (optional -- **omit for private, unlisted content**; `public` lists the page in the gallery and sitemap for Google, Bing and AI search, served on the brewpage.app domain; never put secrets there), `password` (optional), `ttlDays` (days or a string such as `2w`; default 15; the server clamps to 1--30, up to 90 with an owner token in a profile), `filename` (optional, used as title fallback), `showTopBar` (optional boolean -- adds a toolbar with filename, Download button, and theme toggle), `deliveryMode` (optional).
166
+ Parameters: `content` (string), `format` (optional; omit to let the server detect HTML, Markdown, code or JSON; `html` keeps code/JSON as HTML; also `markdown`/`md` or a code language), `namespace` (optional -- **omit for private, unlisted content**; `public` lists the page in the gallery and sitemap for Google, Bing and AI search, served on the brewpage.app domain; never put secrets there), `password` (optional), `ttlDays` (days or a string such as `2w`; default 15; the server clamps to 1--30, up to 90 with an owner token in a profile), `filename` (optional, used as title fallback), `showTopBar` (optional boolean -- adds a toolbar with filename, Download button, and theme toggle), `deliveryMode` (optional), `domain` (optional; a domain you own to connect to an HTML-format page, see Custom domains).
163
167
 
164
168
  The result includes `deliveryModeReason` and a `Delivery notice:` line when the server returns them (see above).
165
169
 
@@ -237,7 +241,7 @@ Publish a single-page or multi-file HTML site. Pass `entryContent` for a single
237
241
 
238
242
  Limits (current values from `get_limits`): a request is at most 25 MiB; a site at most 200 files, 5 MiB per file and 20 MiB in total; each path at most 380 characters. System files such as `.DS_Store` and `__MACOSX/` are skipped and never count; files with an unsupported extension or a too-long path are skipped and reported in notices. Entry: `entry`, else root `index.html`, else the shallowest HTML file. A site limit hit answers 400 with a message naming the limit, as does a PDF/Office file that is unreadable or password-protected; 413 means the request is over 25 MiB, has too many files and form fields, or a file name/path is too long; 422 means no servable file is left; 503 means busy: retry after `Retry-After` seconds (failed publish tools print it).
239
243
 
240
- Parameters: exactly one of `entryContent` (string) or non-empty `files` (array of `{path, content}`), `entry` (optional entry file path, default `index.html`), `namespace` (optional -- omit for private, unlisted content; `public` lists the site in the gallery and sitemap for Google, Bing and AI search; a site always gets its own subdomain), `password` (optional), `ttlDays` (days or a string such as `2w`; default 15; the server clamps to 1--30, up to 90 with an owner token in a profile), `ownerToken` (optional, groups site under an existing owner), `deliveryMode` (optional; fresh sites require `subdomain`).
244
+ Parameters: exactly one of `entryContent` (string) or non-empty `files` (array of `{path, content}`), `entry` (optional entry file path, default `index.html`), `namespace` (optional -- omit for private, unlisted content; `public` lists the site in the gallery and sitemap for Google, Bing and AI search; a site always gets its own subdomain), `password` (optional), `ttlDays` (days or a string such as `2w`; default 15; the server clamps to 1--30, up to 90 with an owner token in a profile), `ownerToken` (optional, groups site under an existing owner), `deliveryMode` (optional; fresh sites require `subdomain`), `domain` (optional; a domain you own to connect to the site, see Custom domains).
241
245
 
242
246
  Example prompt:
243
247
 
@@ -441,6 +445,56 @@ Parameters: `label` (optional), `scope` (`publish` default | `admin`), `expiresI
441
445
 
442
446
  ---
443
447
 
448
+ ### Custom domains
449
+
450
+ 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.
451
+
452
+ 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.
453
+
454
+ `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.
455
+
456
+ ### `set_domain`
457
+
458
+ 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.
459
+
460
+ 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).
461
+
462
+ ```
463
+ set_domain(type="html", namespace="public", id="aBcDeFgHiJ", hostname="www.example.com")
464
+ ```
465
+
466
+ 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.
467
+
468
+ ---
469
+
470
+ ### `list_domains`
471
+
472
+ 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.
473
+
474
+ Parameters: `ownerToken` (admin token of the profile; optional, default `BREWPAGE_OWNER_TOKEN`, local server only).
475
+
476
+ ---
477
+
478
+ ### `check_domain`
479
+
480
+ 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`.
481
+
482
+ Parameters: either `type` + `namespace` + `id`, or `hostname`; `ownerToken` (optional).
483
+
484
+ ```
485
+ check_domain(type="html", namespace="public", id="aBcDeFgHiJ")
486
+ ```
487
+
488
+ ---
489
+
490
+ ### `remove_domain`
491
+
492
+ Remove a custom domain from the profile and free its slot (`DELETE /api/publications/{html|site}/{ns}/{id}/domain` or `DELETE /api/domains/{hostname}`). The BrewPage link keeps working; the DNS records can be deleted at the DNS provider afterwards.
493
+
494
+ Parameters: either `type` + `namespace` + `id`, or `hostname`; `ownerToken` (optional; by hostname an admin token of the profile).
495
+
496
+ ---
497
+
444
498
  ### `get_stats`
445
499
 
446
500
  Get platform-wide BrewPage usage statistics (page count, file count, storage, daily totals). Supports an optional IANA timezone for the "today" boundary.
@@ -495,6 +549,10 @@ get_limits()
495
549
  | "What did I store under that key?" | `get_kv` | By `namespace` + `id` + `key` |
496
550
  | "List my published pages" / "What did I publish?" | `search_gallery` | Set `mine=true` + `ownerToken` |
497
551
  | "Browse the public BrewPage gallery" | `search_gallery` | Optional `q`, `sort=date\|views` |
552
+ | "Put this page on my own domain" | `set_domain` | Owner token in a free profile; returns the DNS records to create |
553
+ | "I added the DNS records, is my domain live?" | `check_domain` | Status `pending_dns` -> `verifying` -> `active` |
554
+ | "Which domains do I have?" | `list_domains` | Admin token of the profile |
555
+ | "Disconnect my domain" | `remove_domain` | The BrewPage link keeps working |
498
556
 
499
557
  ## Owner Token
500
558
 
@@ -547,6 +605,14 @@ Every publish response includes an **owner token** -- the only credential that a
547
605
 
548
606
  ## Changelog
549
607
 
608
+ ## 1.18.1 -- 2026-11-10
609
+
610
+ - Publish results tell the assistant to give the user the management link together with the page link: anyone who has it can edit or delete the publication, so it is never posted publicly. Text only: no tool or input change. Contract 2.20.5. `User-Agent: brewpage-mcp/1.18.1`.
611
+
612
+ ## 1.18.0 -- 2026-11-10
613
+
614
+ - Custom domains: four new tools. `set_domain` connects a domain you own to an HTML page or site and returns the DNS records to create at your DNS provider; `check_domain` checks DNS and reports the status (`pending_dns`, `verifying`, `active`); `list_domains` lists the profile's domains; `remove_domain` removes one and frees its slot. `publish_html` and `publish_site` take an optional `domain`. Free, 5 per profile; the owner token must be in a BrewPage profile (free sign-in at account.brewpage.app). Twenty-five tools. `User-Agent: brewpage-mcp/1.18.0`.
615
+
550
616
  ## 1.17.0 -- 2026-11-09
551
617
 
552
618
  - New tool `set_title`: rename an HTML page or site you own (owner token in a BrewPage profile); the title shows in the gallery, profile, search and link cards and stays when you republish. Empty title restores the one from the content. Twenty-one tools. `User-Agent: brewpage-mcp/1.17.0`.
package/dist/http.js CHANGED
@@ -29,7 +29,7 @@ const MAX_HEAVY_PER_CLIENT = 1;
29
29
  const QUEUE_WAIT_MS = 30_000;
30
30
  const MAX_QUEUED = 64;
31
31
  /** Read-only tools with small answers; content reads (get_page/json/kv) stay in the standard pool. */
32
- const LIGHT_TOOLS = new Set(["get_limits", "get_stats", "search_gallery"]);
32
+ const LIGHT_TOOLS = new Set(["get_limits", "get_stats", "search_gallery", "list_domains"]);
33
33
  const BACKEND_TIMEOUT_MS = 60_000;
34
34
  /** Published egress lists (backend `openai-chatgpt-connectors` rules): daily refresh, last good set kept on failure. */
35
35
  const SHARED_LIST_REFRESH_MS = 24 * 60 * 60_000;
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.17.0";
3
+ export declare const USER_AGENT = "brewpage-mcp/1.18.1";
4
4
  export declare const OWNER_TOKEN_ENV = "BREWPAGE_OWNER_TOKEN";
5
5
  export interface ServerOptions {
6
6
  /** BrewPage origin for API calls; default https://brewpage.app. */
package/dist/tools.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from "zod";
3
3
  export const DEFAULT_BASE_URL = "https://brewpage.app";
4
- export const USER_AGENT = "brewpage-mcp/1.17.0";
4
+ export const USER_AGENT = "brewpage-mcp/1.18.1";
5
5
  export const OWNER_TOKEN_ENV = "BREWPAGE_OWNER_TOKEN";
6
6
  const READ_ONLY = { readOnlyHint: true };
7
7
  const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true };
@@ -32,6 +32,106 @@ const CREDENTIAL_CODE = "credential-published";
32
32
  const CREDENTIAL_ALERT_TAIL = "Tell the user now: anyone with this link can use that token; they must remove it and reset it.";
33
33
  /** MCP type name -> REST path segment. */
34
34
  const TYPE_SEGMENTS = { html: "html", site: "sites", file: "files", json: "json", kv: "kv" };
35
+ 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.";
36
+ 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).";
37
+ 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}.`;
38
+ const DOMAIN_EITHER_TOKEN = `By publication: an owner token in the profile. By hostname: an admin token of the profile. Default: ${ENV_DEFAULT}.`;
39
+ const DOMAIN_ADDRESS_ERROR = "Provide either `hostname` (admin token of the profile) or `type`, `namespace` and `id` (an owner token in the profile).";
40
+ const DOMAIN_ARGUMENT = "Optional domain the user owns (www.example.com or example.com) to connect to the new publication. " +
41
+ "Works when the owner token is in a free BrewPage profile, without a password. The publication is always created; " +
42
+ "the result lists the DNS records for the domain owner to create, or says why the domain was not set.";
43
+ /** Plain words for `DomainItem.problem`; other codes print bare. */
44
+ const DOMAIN_PROBLEMS = {
45
+ TXT_MISSING: "the TXT record is not visible yet",
46
+ TXT_WRONG: "the TXT record has another value",
47
+ ROUTING_MISSING: "the CNAME or A record is not visible yet",
48
+ ROUTING_WRONG: "the CNAME or A record points elsewhere",
49
+ FOREIGN_AAAA: "an AAAA record points elsewhere: delete it",
50
+ CAA_BLOCKS: "a CAA record stops the HTTPS certificate: add the CAA record listed",
51
+ DOMAIN_TAKEN: "this hostname is not available",
52
+ QUEUE_FULL: "DNS is right; the next step is queued and retried automatically",
53
+ CERT_PENDING: "the HTTPS certificate is on its way",
54
+ CERT_FAILED: "the HTTPS certificate was not issued: check DNS, then check again",
55
+ DNS_LOST: "the records stayed broken: restore them, then check again",
56
+ CHECK_PAUSED: "automatic checks are paused; a check resumes them",
57
+ RESOLVER_UNAVAILABLE: "DNS did not answer; retried automatically",
58
+ PUBLICATION_GONE: "the publication is gone: set the domain on another one",
59
+ };
60
+ 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.";
61
+ /** One custom domain (`DomainItem`): status, `records[]` verbatim and the next step. */
62
+ function domainLines(item) {
63
+ const { hostname, displayHostname, problem, url } = item;
64
+ const records = Array.isArray(item.records) ? item.records : [];
65
+ const lines = [
66
+ `Domain: ${hostname}${typeof displayHostname === "string" && displayHostname !== hostname ? ` (${displayHostname})` : ""}`,
67
+ `Status: ${item.status}`,
68
+ ];
69
+ if (typeof problem === "string" && problem)
70
+ lines.push(`Problem: ${problem}${DOMAIN_PROBLEMS[problem] ? ` (${DOMAIN_PROBLEMS[problem]})` : ""}`);
71
+ lines.push(`Serving: ${item.serving === true}`);
72
+ if (item.suspended === true)
73
+ lines.push(`suspended: true`);
74
+ if (typeof url === "string" && url)
75
+ lines.push(`Domain URL: ${url}`);
76
+ const shown = item.attachedTo;
77
+ lines.push(shown && typeof shown === "object" ? `Shows: ${shown.type} ${shown.namespace}/${shown.id} · ${shown.link}` : `Shows: no publication yet`);
78
+ if (typeof item.provider === "string" && item.provider)
79
+ lines.push(`DNS provider: ${item.provider}`);
80
+ if (item.deliveryModeChanged === true)
81
+ lines.push(`The publication moved to its own BrewPage subdomain (a custom domain needs it); its BrewPage link keeps working.`);
82
+ if (typeof item.detached === "string" && item.detached)
83
+ lines.push(`Earlier domain ${item.detached} stays in the profile, not attached.`);
84
+ for (const [field, label] of [["checkedAt", "Checked"], ["nextCheckAt", "Next automatic check"], ["graceEndsAt", "Shown until"]]) {
85
+ if (typeof item[field] === "string")
86
+ lines.push(`${label}: ${item[field]}`);
87
+ }
88
+ lines.push(`DNS records (records[], verbatim):`, ...records.map((record) => ` ${JSON.stringify(record)}`));
89
+ if (records.some((record) => record.optional === true))
90
+ lines.push(`A row with "optional":true is an alternative to the A record: one of the two is enough.`);
91
+ if (records.some((record) => record.type === "AAAA"))
92
+ lines.push(`An AAAA row is a record to delete, not to create.`);
93
+ if (item.provider === "cloudflare")
94
+ lines.push(`Cloudflare: Proxy status "DNS only" on the CNAME or A record.`);
95
+ if (item.suspended === true)
96
+ return lines;
97
+ if (item.status === "pending_dns")
98
+ lines.push(DOMAIN_NEXT_CREATE);
99
+ if (item.status === "verifying")
100
+ lines.push(`Next: DNS is found and HTTPS is being issued. Call check_domain again shortly.`);
101
+ if (item.status === "active")
102
+ lines.push(`Live: the domain shows the publication over HTTPS. The BrewPage link keeps working.`);
103
+ if (item.status === "lost")
104
+ lines.push(`Next: DNS changed after going live. Ask the user to restore exactly these records at their DNS provider, then call check_domain.`);
105
+ return lines;
106
+ }
107
+ /** Create with `domain`: the returned `customDomain`, else a line saying it was not set (the reason is a `domain-*` notice). */
108
+ function publishDomainLines(data, requested) {
109
+ const item = data.customDomain;
110
+ if (item && typeof item === "object")
111
+ return [``, `CUSTOM DOMAIN`, ...domainLines(item)];
112
+ if (!requested)
113
+ return [];
114
+ const reason = notices(data).some((n) => n.code.startsWith("domain-")) ? " The domain notice above says why." : "";
115
+ return [``, `CUSTOM DOMAIN: ${requested} was not set; the publication is live at its BrewPage link.${reason} set_domain connects it later.`];
116
+ }
117
+ /** Next step after a custom-domain refusal; other codes keep the server answer alone. */
118
+ function domainHints(data, byHostname) {
119
+ const { code, limit, used } = (data ?? {});
120
+ if (code === "DOMAIN_NEEDS_ACCOUNT") {
121
+ 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."];
122
+ }
123
+ if (code === "DOMAIN_LIMIT" && typeof limit === "number" && limit > 0) {
124
+ 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.`];
125
+ }
126
+ if (code === "ADMIN_TOKEN_REQUIRED") {
127
+ 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." : "")];
128
+ }
129
+ if (code === "DOMAIN_CHECK_TOO_SOON")
130
+ return ["Checked a moment ago: wait, then call check_domain again."];
131
+ if (code === "DOMAIN_NOT_FOUND")
132
+ return ["No custom domain there: list_domains shows the profile's domains; set_domain connects one."];
133
+ return [];
134
+ }
35
135
  /** `/api/<segments>` with each segment encoded; empty, `.` and `..` refused (URL parsing would resolve them away). */
36
136
  export function apiPath(...segments) {
37
137
  for (const segment of segments) {
@@ -67,7 +167,7 @@ const PUBLISH_TOOL_FOR = {
67
167
  "update HTML": "publish_html", "republish site": "publish_site", "update JSON": "publish_json", "set KV": "publish_kv",
68
168
  };
69
169
  /** Owner-tool failure: server answer verbatim plus a hint for wrong-token or no-profile 403, wrong type/missing row, frozen old row. */
70
- function ownerFailure(operation, status, data, fromEnv, headers) {
170
+ function ownerFailure(operation, status, data, fromEnv, headers, extraHints = []) {
71
171
  const lines = [`Failed to ${operation} (${status}): ${JSON.stringify(data)}${retryAfterText(status, headers)}`];
72
172
  const { code, message, result } = (data ?? {});
73
173
  if (status === 403 && code === "OWNER_AUTHORIZATION_FAILED" && message === RENAME_NEEDS_PROFILE) {
@@ -92,6 +192,7 @@ function ownerFailure(operation, status, data, fromEnv, headers) {
92
192
  (typeof republishUrl === "string" ? " republishUrl is a browser-only composer link." : "") +
93
193
  (operation === "set KV" ? " A new KV store holds only the keys published to it." : ""));
94
194
  }
195
+ lines.push(...extraHints);
95
196
  return textResult(lines.join("\n"), true);
96
197
  }
97
198
  const PUBLIC_NAMESPACE = "public";
@@ -178,7 +279,7 @@ const SIGN_IN_HINT = "Optional: sign in free (Google or any email) at https://ac
178
279
  function ownerLinkLines(data) {
179
280
  const lines = [];
180
281
  if (typeof data.manageUrl === "string" && data.manageUrl.length > 0) {
181
- lines.push(``, `MANAGE LINK (private): ${data.manageUrl}`, `Give it to the user privately; never publish it.`);
282
+ 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.`);
182
283
  }
183
284
  if (data.listed === true && !hasCredentialNotice(data))
184
285
  lines.push(``, `VISIBILITY: public, listed. Hide: set_visibility listed=false`);
@@ -228,7 +329,7 @@ export function createServer(options = {}) {
228
329
  const apiFetch = options.fetch ?? fetch;
229
330
  const server = new McpServer({
230
331
  name: "brewpage-mcp",
231
- version: "1.17.0",
332
+ version: "1.18.1",
232
333
  });
233
334
  /** Caller argument first, then the configured default; `fromEnv` marks the configured one. */
234
335
  function ownerTokenFor(argument) {
@@ -280,7 +381,7 @@ export function createServer(options = {}) {
280
381
  function titleLines(data) {
281
382
  return typeof data.title === "string" && data.title ? [`Title: ${data.title}`] : [];
282
383
  }
283
- function formatPublishResponse(data, effectiveNamespace, sentOwnerToken, listable = true) {
384
+ function formatPublishResponse(data, effectiveNamespace, sentOwnerToken, listable = true, requestedDomain) {
284
385
  const lines = [
285
386
  ...credentialAlertLines(data),
286
387
  `Published successfully!`,
@@ -293,6 +394,7 @@ export function createServer(options = {}) {
293
394
  if (data.expiresAt)
294
395
  lines.push(`Expires: ${data.expiresAt}`);
295
396
  lines.push(...hostingResponseLines(data));
397
+ lines.push(...publishDomainLines(data, requestedDomain));
296
398
  lines.push(...ownerTokenLines(data, "update or delete this resource later", sentOwnerToken));
297
399
  lines.push(...ownerLinkLines(data));
298
400
  const ns = (data.namespace ?? effectiveNamespace) || "";
@@ -307,6 +409,7 @@ export function createServer(options = {}) {
307
409
  "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). " +
308
410
  "A `public` page without deliveryMode may get its own subdomain if it needs browser features a BrewPage link lacks; " +
309
411
  "the result then has deliveryModeReason and a delivery notice. Markdown raw HTML is allowlisted: scripts, styles, event handlers, forms, embeds removed. " +
412
+ "Optional `domain` connects a domain the user owns to an HTML-format page. " +
310
413
  MANAGE_URL_TEXT + " " + SECRETS,
311
414
  inputSchema: {
312
415
  content: z.string().describe("HTML, Markdown, code or JSON to publish"),
@@ -334,11 +437,12 @@ export function createServer(options = {}) {
334
437
  .boolean()
335
438
  .optional()
336
439
  .describe("Add a thin top toolbar (filename + Download button + theme toggle) on the served page. Default: hidden."),
440
+ domain: z.string().optional().describe(DOMAIN_ARGUMENT),
337
441
  ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
338
442
  },
339
- }, async ({ content, format, namespace, password, ttlDays, filename, showTopBar, deliveryMode, ownerToken }) => {
443
+ }, async ({ content, format, namespace, password, ttlDays, filename, showTopBar, deliveryMode, domain, ownerToken }) => {
340
444
  const effectiveNamespace = resolveNamespace(namespace);
341
- const path = withQuery(apiPath("html"), [["ns", effectiveNamespace], ["format", format?.toLowerCase()], ["ttl", ttlDays]]);
445
+ const path = withQuery(apiPath("html"), [["ns", effectiveNamespace], ["format", format?.toLowerCase()], ["ttl", ttlDays], ["domain", domain || undefined]]);
342
446
  const body = { content };
343
447
  if (filename)
344
448
  body.filename = filename;
@@ -368,7 +472,7 @@ export function createServer(options = {}) {
368
472
  content: [
369
473
  {
370
474
  type: "text",
371
- text: formatPublishResponse(data, effectiveNamespace, owner),
475
+ text: formatPublishResponse(data, effectiveNamespace, owner, true, domain || undefined),
372
476
  },
373
477
  ],
374
478
  };
@@ -562,7 +666,7 @@ export function createServer(options = {}) {
562
666
  ...toolMeta("Publish site", ADDITIVE),
563
667
  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. " +
564
668
  "Static only: HTML, CSS, JS (incl. built React/Vue bundles) run in the browser; server code (PHP etc.) never runs. " + SITE_LIMITS_TEXT +
565
- " " + MANAGE_URL_TEXT + " " + SECRETS,
669
+ " Optional `domain` connects a domain the user owns. " + MANAGE_URL_TEXT + " " + SECRETS,
566
670
  inputSchema: {
567
671
  deliveryMode: DELIVERY_MODE_SCHEMA,
568
672
  entryContent: z
@@ -586,9 +690,10 @@ export function createServer(options = {}) {
586
690
  .optional()
587
691
  .describe("Optional password to protect the site"),
588
692
  ttlDays: TTL_SCHEMA,
693
+ domain: z.string().optional().describe(DOMAIN_ARGUMENT),
589
694
  ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
590
695
  },
591
- }, async ({ entryContent, files, entry, namespace, password, ttlDays, ownerToken, deliveryMode }) => {
696
+ }, async ({ entryContent, files, entry, namespace, password, ttlDays, ownerToken, deliveryMode, domain }) => {
592
697
  const hasEntry = typeof entryContent === "string";
593
698
  const hasFiles = Array.isArray(files) && files.length > 0;
594
699
  if (hasEntry === hasFiles) {
@@ -624,7 +729,7 @@ export function createServer(options = {}) {
624
729
  if (deliveryMode)
625
730
  headers["X-Delivery-Mode"] = deliveryMode;
626
731
  const effectiveNamespace = resolveNamespace(namespace);
627
- const url = baseUrl + withQuery(apiPath("sites"), [["entry", entry || undefined], ["ns", effectiveNamespace], ["ttl", ttlDays || undefined]]);
732
+ const url = baseUrl + withQuery(apiPath("sites"), [["entry", entry || undefined], ["ns", effectiveNamespace], ["ttl", ttlDays || undefined], ["domain", domain || undefined]]);
628
733
  const res = await bpFetch(url, { method: "POST", headers, body: formData });
629
734
  const data = await res.json().catch(() => null);
630
735
  if (!res.ok) {
@@ -659,6 +764,7 @@ export function createServer(options = {}) {
659
764
  if (site.expiresAt)
660
765
  lines.push(`Expires: ${site.expiresAt}`);
661
766
  lines.push(...hostingResponseLines(site));
767
+ lines.push(...publishDomainLines(site, domain || undefined));
662
768
  lines.push(...ownerTokenLines(site, "view info or delete this site", owner));
663
769
  lines.push(...ownerLinkLines(site));
664
770
  const ns = (site.namespace || effectiveNamespace) || "";
@@ -1244,5 +1350,108 @@ export function createServer(options = {}) {
1244
1350
  `Save it now; it cannot be shown again. Never publish it.`,
1245
1351
  ].join("\n"));
1246
1352
  });
1353
+ /** Publication route or hostname route of a custom domain; null when the arguments name neither or both. */
1354
+ function domainPath(type, namespace, id, hostname, suffix = []) {
1355
+ const parts = [type, namespace, id].filter((part) => part !== undefined).length;
1356
+ if (hostname !== undefined && parts === 0)
1357
+ return apiPath("domains", hostname, ...suffix);
1358
+ if (hostname === undefined && parts === 3)
1359
+ return apiPath("publications", type, namespace, id, "domain", ...suffix);
1360
+ return null;
1361
+ }
1362
+ server.registerTool("set_domain", {
1363
+ ...toolMeta("Connect custom domain", ADDITIVE),
1364
+ description: "Connect a domain the user owns (www.example.com or example.com) to one HTML page or site. " + DOMAIN_PROFILE_TEXT +
1365
+ " 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. " +
1366
+ "A DNS check after that moves the status pending_dns -> verifying -> active; HTTPS is issued automatically. " +
1367
+ "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.",
1368
+ inputSchema: {
1369
+ type: z.enum(["html", "site"]).describe("Publication type"),
1370
+ namespace: z.string().describe("Publication namespace"),
1371
+ id: z.string().describe("Publication ID"),
1372
+ hostname: z.string().describe("Domain or subdomain the user owns, e.g. www.example.com or example.com"),
1373
+ ownerToken: z.string().optional().describe(DOMAIN_OWNER_TOKEN),
1374
+ },
1375
+ }, async ({ type, namespace, id, hostname, ownerToken }) => {
1376
+ const auth = ownerTokenFor(ownerToken);
1377
+ if (!auth.token)
1378
+ return textResult(NO_OWNER_TOKEN, true);
1379
+ const path = apiPath("publications", type, namespace, id, "domain");
1380
+ const { ok, status, data, headers: answerHeaders } = await apiRequest("PUT", path, { hostname }, { "X-Owner-Token": auth.token });
1381
+ if (!ok)
1382
+ return ownerFailure("set domain", status, data, auth.fromEnv, answerHeaders, domainHints(data, false));
1383
+ const lead = status === 201 ? "Custom domain connected." : "Custom domain set: it was already in the profile (moved here or unchanged).";
1384
+ return textResult([lead, ``, ...domainLines(data)].join("\n"));
1385
+ });
1386
+ server.registerTool("list_domains", {
1387
+ ...toolMeta("List custom domains", READ_ONLY),
1388
+ 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.",
1389
+ inputSchema: {
1390
+ ownerToken: z.string().optional().describe(`Admin token of the profile. Default: ${ENV_DEFAULT}.`),
1391
+ },
1392
+ }, async ({ ownerToken }) => {
1393
+ const auth = ownerTokenFor(ownerToken);
1394
+ if (!auth.token)
1395
+ return textResult(NO_OWNER_TOKEN, true);
1396
+ const { ok, status, data, headers: answerHeaders } = await apiRequest("GET", "/api/domains", undefined, { "X-Owner-Token": auth.token });
1397
+ if (!ok)
1398
+ return ownerFailure("list domains", status, data, auth.fromEnv, answerHeaders, domainHints(data, false));
1399
+ const list = data;
1400
+ const items = Array.isArray(list.items) ? list.items : [];
1401
+ const lines = [`Custom domains: ${list.used} of ${list.limit} used`];
1402
+ if (items.length === 0)
1403
+ lines.push(``, `None yet: set_domain connects one to an HTML page or site.`);
1404
+ for (const item of items)
1405
+ lines.push(``, ...domainLines(item));
1406
+ return textResult(lines.join("\n"));
1407
+ });
1408
+ server.registerTool("check_domain", {
1409
+ ...toolMeta("Check custom domain DNS", ADDITIVE),
1410
+ description: "Check the DNS of a custom domain now and return its status and records. Status: pending_dns (records not seen yet), " +
1411
+ "verifying (DNS found, HTTPS being issued), active (live). " + DOMAIN_ADDRESS_TEXT +
1412
+ " Checks are spaced: a repeat too soon answers 429 with Retry-After.",
1413
+ inputSchema: {
1414
+ type: z.enum(["html", "site"]).optional().describe("Publication type; with namespace and id, instead of hostname"),
1415
+ namespace: z.string().optional().describe("Publication namespace"),
1416
+ id: z.string().optional().describe("Publication ID"),
1417
+ hostname: z.string().optional().describe("Custom domain, e.g. www.example.com; instead of type, namespace and id"),
1418
+ ownerToken: z.string().optional().describe(DOMAIN_EITHER_TOKEN),
1419
+ },
1420
+ }, async ({ type, namespace, id, hostname, ownerToken }) => {
1421
+ const auth = ownerTokenFor(ownerToken);
1422
+ if (!auth.token)
1423
+ return textResult(NO_OWNER_TOKEN, true);
1424
+ const path = domainPath(type, namespace, id, hostname, ["check"]);
1425
+ if (path === null)
1426
+ return textResult(DOMAIN_ADDRESS_ERROR, true);
1427
+ const { ok, status, data, headers: answerHeaders } = await apiRequest("POST", path, undefined, { "X-Owner-Token": auth.token });
1428
+ if (!ok)
1429
+ return ownerFailure("check domain", status, data, auth.fromEnv, answerHeaders, domainHints(data, hostname !== undefined));
1430
+ return textResult([`DNS checked.`, ``, ...domainLines(data)].join("\n"));
1431
+ });
1432
+ server.registerTool("remove_domain", {
1433
+ ...toolMeta("Remove custom domain", DESTRUCTIVE),
1434
+ 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. " +
1435
+ DOMAIN_ADDRESS_TEXT + " DNS records at the provider stay until the domain owner deletes them.",
1436
+ inputSchema: {
1437
+ type: z.enum(["html", "site"]).optional().describe("Publication type; with namespace and id, instead of hostname"),
1438
+ namespace: z.string().optional().describe("Publication namespace"),
1439
+ id: z.string().optional().describe("Publication ID"),
1440
+ hostname: z.string().optional().describe("Custom domain, e.g. www.example.com; instead of type, namespace and id"),
1441
+ ownerToken: z.string().optional().describe(DOMAIN_EITHER_TOKEN),
1442
+ },
1443
+ }, async ({ type, namespace, id, hostname, ownerToken }) => {
1444
+ const auth = ownerTokenFor(ownerToken);
1445
+ if (!auth.token)
1446
+ return textResult(NO_OWNER_TOKEN, true);
1447
+ const path = domainPath(type, namespace, id, hostname);
1448
+ if (path === null)
1449
+ return textResult(DOMAIN_ADDRESS_ERROR, true);
1450
+ const { ok, status, data, headers: answerHeaders } = await apiRequest("DELETE", path, undefined, { "X-Owner-Token": auth.token });
1451
+ if (!ok)
1452
+ return ownerFailure("remove domain", status, data, auth.fromEnv, answerHeaders, domainHints(data, hostname !== undefined));
1453
+ const what = hostname !== undefined ? `Custom domain removed: ${hostname}` : `No custom domain on ${type} ${namespace}/${id} now`;
1454
+ return textResult(`${what} · slot free · BrewPage link unchanged\nIts DNS records can be deleted at the DNS provider.`);
1455
+ });
1247
1456
  return server;
1248
1457
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brewpage-mcp",
3
- "version": "1.17.0",
3
+ "version": "1.18.1",
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",