brewpage-mcp 1.12.1 → 1.13.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.
Files changed (3) hide show
  1. package/README.md +65 -17
  2. package/dist/index.js +259 -148
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,19 +2,22 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/brewpage-mcp.svg)](https://www.npmjs.com/package/brewpage-mcp)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Brewpage MCP server](https://mcprush.com/badge/brewpage-mcp/card.svg)](https://mcprush.com/to/brewpage-mcp?ref=badge)
5
6
 
6
7
  MCP server for [BrewPage](https://brewpage.app) -- publish and manage HTML, KV, JSON, and file content directly from AI assistants.
7
8
 
8
9
  ## What is BrewPage
9
10
 
10
- [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 accounts, 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 seventeen 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 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
+
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.
11
14
 
12
15
  Choosing a host? See how BrewPage compares with GitHub Pages, Netlify Drop, Tiiny Host and other tools: [brewpage.app/compare](https://brewpage.app/compare).
13
16
 
14
17
  ## Quick Start
15
18
 
16
19
  ```bash
17
- npx -y brewpage-mcp@1.12.1
20
+ npx -y brewpage-mcp@1.13.1
18
21
  ```
19
22
 
20
23
  Requires Node.js 20 or newer. This starts the stdio server for an MCP client.
@@ -30,7 +33,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
30
33
  "mcpServers": {
31
34
  "brewpage": {
32
35
  "command": "npx",
33
- "args": ["-y", "brewpage-mcp@1.12.1"]
36
+ "args": ["-y", "brewpage-mcp@1.13.1"]
34
37
  }
35
38
  }
36
39
  }
@@ -41,7 +44,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
41
44
  Register a project-scoped stdio server:
42
45
 
43
46
  ```bash
44
- claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.12.1
47
+ claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.13.1
45
48
  ```
46
49
 
47
50
  This writes `.mcp.json` in the project root. Use `--scope user` for user-scoped registration. See the [official Claude Code MCP guide](https://code.claude.com/docs/en/mcp).
@@ -55,7 +58,7 @@ Open **Settings > MCP** and add:
55
58
  "mcpServers": {
56
59
  "brewpage": {
57
60
  "command": "npx",
58
- "args": ["-y", "brewpage-mcp@1.12.1"]
61
+ "args": ["-y", "brewpage-mcp@1.13.1"]
59
62
  }
60
63
  }
61
64
  }
@@ -69,7 +72,7 @@ Open the Cline MCP settings panel and add:
69
72
  {
70
73
  "brewpage": {
71
74
  "command": "npx",
72
- "args": ["-y", "brewpage-mcp@1.12.1"]
75
+ "args": ["-y", "brewpage-mcp@1.13.1"]
73
76
  }
74
77
  }
75
78
  ```
@@ -77,13 +80,13 @@ Open the Cline MCP settings panel and add:
77
80
  ### Global Install
78
81
 
79
82
  ```bash
80
- npm install -g brewpage-mcp@1.12.1
83
+ npm install -g brewpage-mcp@1.13.1
81
84
  brewpage-mcp
82
85
  ```
83
86
 
84
87
  ## Tools
85
88
 
86
- Seventeen tools are available. Creation returns an owner token; keep it to modify or delete the resource later. Updates (`PUT`/`PATCH`) require the original `ownerToken`. Supported password inputs use `X-Password`.
89
+ Twenty 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`. Supported password inputs use `X-Password`. Secrets allowed; likely ones are flagged in notices. For a secret: private namespace + password.
87
90
 
88
91
  | Tool | Purpose |
89
92
  |------|---------|
@@ -100,14 +103,17 @@ Seventeen tools are available. Creation returns an owner token; keep it to modif
100
103
  | `set_kv` | Set or replace a key in an owned KV store |
101
104
  | `get_kv` | Read a KV value |
102
105
  | `update_hosting` | Change eligible delivery mode with owner token and hosting version |
103
- | `delete_resource` | Delete HTML, JSON, KV, or file content |
106
+ | `delete_resource` | Delete an HTML page, site, JSON document, KV store or file |
107
+ | `set_visibility` | Hide or show a public publication in gallery & search; link unchanged |
108
+ | `bind_token` | Add an owner token to your profile (admin token) |
109
+ | `mint_token` | Mint a profile token, shown once (admin token) |
104
110
  | `search_gallery` | Browse public content or list owned resources |
105
111
  | `get_stats` | Read platform-wide statistics |
106
112
  | `get_limits` | Read the current upload limits (`GET /api/limits`) |
107
113
 
108
114
  All five publish tools accept optional `deliveryMode: "path" | "subdomain"`, sent only as `X-Delivery-Mode`; authored JSON and multipart bodies keep their existing format. Visibility and password protection are separate choices. Omitted namespace still generates an unlisted `priv-<random>` namespace; anyone with an unpassworded unlisted link can read it. `public` grants gallery/search eligibility only; password-protected content remains excluded and unlisted, protected or otherwise ineligible native responses carry `noindex`; eligible public HTML, sites and files may permit indexing.
109
115
 
110
- The MCP server wraps a subset of the backend-generated public [REST contract](../openapi/openapi.json). TTL inputs take days or a string such as `2w`/`36h` (default 15); the server rounds to whole days and clamps to 1–30 (owner-specific caps and eligible automatic extensions can differ). Use returned `expiresAt`. Every tolerated-input adjustment (TTL clamp, format alias, skipped site member, normalised namespace) comes back in the response `notices` array and is printed as `Notice (code): message`. `get_page` reads stored rendered HTML or format-specific code/text from the API; it does not call the owner-only original-source endpoint or a browser viewer. `publish_file` uploads from a source URL and does not expose password or TTL inputs.
116
+ The MCP server wraps a subset of the backend-generated public [REST contract](../openapi/openapi.json). TTL inputs take days or a string such as `2w`/`36h` (default 15); the server rounds to whole days and clamps to 1–30 (owner-specific caps and eligible automatic extensions can differ). Use returned `expiresAt`. Every tolerated-input adjustment (TTL clamp, format alias, skipped site member, normalised namespace) comes back in the response `notices` array and is printed as `Notice (code): message`; `secret-detected` and `manage-link-disabled` print as `WARNING (code): message`: relay them to the user. A minted `manageUrl` prints as `MANAGE LINK (private): …`: give it to the user privately, never publish it. A listed public result prints `VISIBILITY: public, listed. Hide: set_visibility listed=false`. `get_page` reads stored rendered HTML or format-specific code/text from the API; it does not call the owner-only original-source endpoint or a browser viewer. `publish_file` uploads from a source URL and does not expose password or TTL inputs.
111
117
 
112
118
  For a fresh publication, sites and HTML pages of format `HTML` outside `public` (including the generated unlisted namespace) always use Dedicated subdomain (`subdomain`); omit `deliveryMode` there. An explicit `path` there is not rejected: the publication is created on its subdomain and the result reports `deliveryModeMatched: false`, `deliveryModeReason: "policy:subdomain-required"` and a `Delivery notice:` line. Everything else in any namespace (Markdown, code/text, files, JSON, KV, public HTML) defaults to Promotion (`path`) and may choose Dedicated subdomain, including password-protected content. Promotion uses a BrewPage path with stricter active-document restrictions. Each NEW site gets one publication-specific host for its complete bundle, preserving relative/root-relative paths; missing assets return 404, with no SPA fallback. Third-party widgets may still need provider configuration.
113
119
 
@@ -341,7 +347,7 @@ get_kv(namespace="public", id="aBcDeFgHiJ", key="theme")
341
347
 
342
348
  Browse the public gallery of BrewPage pages, or list resources you own. Returns a paged result with metadata (id, title, view count, created date). All parameters are optional; with no parameters this returns the most recent public pages.
343
349
 
344
- Parameters: `q` (optional search query), `page` (optional, 0-indexed), `size` (optional page size), `sort` (optional `date` | `views`), `mine` (optional boolean -- list only resources owned by `ownerToken`), `ownerToken` (required only when `mine=true`).
350
+ Parameters: `q` (optional search query), `page` (optional, 1-based), `size` (optional page size), `sort` (optional `date` | `views`), `mine` (optional boolean -- list only resources owned by `ownerToken`), `ownerToken` (required only when `mine=true`).
345
351
 
346
352
  Example prompt:
347
353
 
@@ -357,11 +363,9 @@ search_gallery(mine=true, ownerToken="tok_...", sort="date")
357
363
 
358
364
  ### `delete_resource`
359
365
 
360
- Delete a BrewPage resource (HTML page, KV store, JSON document, or file) using the owner token received at creation.
361
-
362
- Parameters: `type` (`html` | `kv` | `json` | `file`), `namespace` (string), `id` (string), `ownerToken` (string).
366
+ Delete a BrewPage resource (HTML page, site, KV store, JSON document, or file) using the owner token received at creation.
363
367
 
364
- Sites are not supported by this tool; use the REST site deletion endpoint.
368
+ Parameters: `type` (`html` | `site` | `kv` | `json` | `file`), `namespace` (string), `id` (string), `ownerToken` (optional; default `BREWPAGE_OWNER_TOKEN`). Path segments are URL-encoded.
365
369
 
366
370
  Example prompt:
367
371
 
@@ -373,6 +377,36 @@ delete_resource(type="html", namespace="public", id="aBcDeFgHiJ", ownerToken="to
373
377
 
374
378
  ---
375
379
 
380
+ ### `set_visibility`
381
+
382
+ Hide or show a public publication in gallery & search; the link stays the same. Only public, password-free HTML, sites and files can be listed; others answer with a `visibility-not-listable` notice.
383
+
384
+ Parameters: `type` (`html` | `site` | `file` | `json` | `kv`), `namespace`, `id`, `listed` (boolean), `ownerToken` (optional).
385
+
386
+ ```
387
+ set_visibility(type="html", namespace="public", id="aBcDeFgHiJ", listed=false)
388
+ ```
389
+
390
+ Output: `LISTED: false · link unchanged`.
391
+
392
+ ---
393
+
394
+ ### `bind_token`
395
+
396
+ Add an owner token to your BrewPage profile (as `publish`) with an `admin` token of that profile (`POST /api/owner-token/bind`). Output: `BOUND …ab12 · 3/20 active`; tokens are never printed in full.
397
+
398
+ Parameters: `token`, `ownerToken` (admin token; optional, default `BREWPAGE_OWNER_TOKEN`).
399
+
400
+ ---
401
+
402
+ ### `mint_token`
403
+
404
+ Mint a new profile token with an `admin` token (`POST /api/owner-token/mint`). The new token is shown once; the admin token is never printed.
405
+
406
+ Parameters: `label` (optional), `scope` (`publish` default | `admin`), `expiresIn` (`30d` | `90d` | `1y` | `never`; default admin 90d, publish never), `ownerToken` (admin token; optional).
407
+
408
+ ---
409
+
376
410
  ### `get_stats`
377
411
 
378
412
  Get platform-wide BrewPage usage statistics (page count, file count, storage, daily totals). Supports an optional IANA timezone for the "today" boundary.
@@ -391,7 +425,7 @@ get_stats()
391
425
 
392
426
  ### `get_limits`
393
427
 
394
- Read the current upload limits from `GET /api/limits` (no auth). Sizes are bytes: `requestMaxBytes` plus `sites.maxFiles`, `maxFileBytes`, `maxTotalBytes` (uncompressed), `maxArchiveExpansionBytes`, `maxPathChars`, `maxPathBytes`, `maxNameBytes`, `defaultTtlDays`, `maxTtlDays`, `allowedExtensions`, `skippedFileNames` and `skippedRootFolders`. Call it before publishing a large or multi-file site.
428
+ Read the current upload limits from `GET /api/limits` (no auth; with `BREWPAGE_OWNER_TOKEN` also `reach` and `ownerMaxTtlDays`). Sizes are bytes: `requestMaxBytes` plus `sites.maxFiles`, `maxFileBytes`, `maxTotalBytes` (uncompressed), `maxArchiveExpansionBytes`, `maxPathChars`, `maxPathBytes`, `maxNameBytes`, `defaultTtlDays`, `maxTtlDays`, `allowedExtensions`, `skippedFileNames` and `skippedRootFolders`. Call it before publishing a large or multi-file site.
395
429
 
396
430
  Parameters: none.
397
431
 
@@ -432,7 +466,8 @@ get_limits()
432
466
 
433
467
  Every publish response includes an **owner token** -- the only credential that allows updating or deleting your content. **Save it. It cannot be recovered.**
434
468
 
435
- - Reuse it with `publish_site`, `publish_json`, or `publish_kv` to group new resources under one owner; REST creates accept `X-Owner-Token`.
469
+ - Reuse it with any publish tool (`ownerToken`) to group new resources under one owner; REST creates accept `X-Owner-Token`.
470
+ - Set `BREWPAGE_OWNER_TOKEN` to use one token for every call; the output then names it instead of printing it.
436
471
  - Pass it to `delete_resource` to remove content.
437
472
  - Pass it to `search_gallery` with `mine=true` to list owned resources. `get_page` has no owner-token input; protected reads use `password`.
438
473
 
@@ -441,6 +476,7 @@ Every publish response includes an **owner token** -- the only credential that a
441
476
  | Variable | Default | Description |
442
477
  |----------|---------|-------------|
443
478
  | `BREWPAGE_URL` | `https://brewpage.app` | Authorized apex API base URL; never a publication host |
479
+ | `BREWPAGE_OWNER_TOKEN` | unset | Optional default owner token (32 letters/digits) for every call; unset = new token per create |
444
480
 
445
481
  ## Links
446
482
 
@@ -455,6 +491,18 @@ Every publish response includes an **owner token** -- the only credential that a
455
491
 
456
492
  ## Changelog
457
493
 
494
+ ## 1.13.1 -- 2026-10-25
495
+
496
+ - Old publications (created before the new link format): update tools say they answer `409 OLD_PUBLICATION_READ_ONLY`; that failure adds a hint to publish the content anew and give the user the new URL and owner token.
497
+
498
+ ## 1.13.0 -- 2026-10-25
499
+
500
+ - Identifying `User-Agent: brewpage-mcp/1.13.0` on every BrewPage call.
501
+ - Optional `BREWPAGE_OWNER_TOKEN` default; `ownerToken` on `publish_html` and `publish_file`; owner tools fail before HTTP without a token.
502
+ - New tools: `set_visibility`, `bind_token`, `mint_token` (twenty tools).
503
+ - `delete_resource` deletes sites and URL-encodes path segments.
504
+ - Prints `MANAGE LINK (private)`, `VISIBILITY` and `WARNING (code)` lines; a wrong-token 403 adds a hint.
505
+
458
506
  ## 1.12.1 -- 2026-10-22
459
507
 
460
508
  - Package README links the BrewPage comparison page (https://brewpage.app/compare). Same seventeen tools, inputs and HTTP behavior.
package/dist/index.js CHANGED
@@ -3,10 +3,68 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
5
  const BASE_URL = process.env.BREWPAGE_URL || "https://brewpage.app";
6
+ const USER_AGENT = "brewpage-mcp/1.13.1";
6
7
  const server = new McpServer({
7
8
  name: "brewpage-mcp",
8
- version: "1.12.1",
9
+ version: "1.13.1",
9
10
  });
11
+ const OWNER_TOKEN_SHAPE = /^[A-Za-z0-9]{32}$/;
12
+ const OWNER_TOKEN_ENV = "BREWPAGE_OWNER_TOKEN";
13
+ /** Optional default owner token for every call; a malformed value is ignored with one stderr line. */
14
+ const ENV_OWNER_TOKEN = (() => {
15
+ const raw = process.env[OWNER_TOKEN_ENV]?.trim();
16
+ if (!raw)
17
+ return undefined;
18
+ if (OWNER_TOKEN_SHAPE.test(raw))
19
+ return raw;
20
+ console.error(`${OWNER_TOKEN_ENV} ignored: expected 32 letters/digits.`);
21
+ return undefined;
22
+ })();
23
+ const NO_OWNER_TOKEN = `No owner token: pass ownerToken or set ${OWNER_TOKEN_ENV}.`;
24
+ const CREATE_OWNER_TOKEN_DESCRIPTION = `Optional. Default: ${OWNER_TOKEN_ENV}, else a new token per create.`;
25
+ const OWNER_TOKEN_DESCRIPTION = `Owner token from create. Default: ${OWNER_TOKEN_ENV}.`;
26
+ const SECRETS = "Secrets allowed; likely ones are flagged in notices. For a secret: private namespace + password.";
27
+ const MANAGE_URL_TEXT = "Returns manageUrl (private): give it to the user privately; never publish it.";
28
+ const OLD_READ_ONLY_CODE = "OLD_PUBLICATION_READ_ONLY";
29
+ const OLD_READ_ONLY_TEXT = ` Old publications (created before the new link format) answer 409 ${OLD_READ_ONLY_CODE}.`;
30
+ /** Notice codes the agent must relay to the user. */
31
+ const WARNING_CODES = new Set(["secret-detected", "manage-link-disabled"]);
32
+ /** MCP type name -> REST path segment. */
33
+ const TYPE_SEGMENTS = { html: "html", site: "sites", file: "files", json: "json", kv: "kv" };
34
+ /** Caller argument first, then the configured default; `fromEnv` marks the configured one. */
35
+ function ownerTokenFor(argument) {
36
+ if (argument && argument.length > 0)
37
+ return { token: argument, fromEnv: false };
38
+ return { token: ENV_OWNER_TOKEN, fromEnv: ENV_OWNER_TOKEN !== undefined };
39
+ }
40
+ /** Only the last 4 characters: tokens never reach a transcript in full. */
41
+ function maskToken(token) {
42
+ return `…${token.slice(-4)}`;
43
+ }
44
+ function textResult(text, isError = false) {
45
+ return isError ? { content: [{ type: "text", text }], isError: true } : { content: [{ type: "text", text }] };
46
+ }
47
+ /** Owner-tool failure: the server answer verbatim plus a hint for a plain wrong-token 403 or a frozen old publication. */
48
+ function ownerFailure(operation, status, data, fromEnv) {
49
+ const lines = [`Failed to ${operation} (${status}): ${JSON.stringify(data)}`];
50
+ const code = data?.code;
51
+ if (status === 403 && (code === undefined || code === "OWNER_AUTHORIZATION_FAILED")) {
52
+ lines.push(`Wrong owner token for this publication. Use ownerToken from create.${fromEnv ? ` Sent: ${OWNER_TOKEN_ENV}.` : ""}`);
53
+ }
54
+ if (status === 409 && code === OLD_READ_ONLY_CODE) {
55
+ lines.push("Read-only old publication: cannot be updated. Publish the content anew with the matching " +
56
+ "publish tool in the same namespace; give the user the NEW URL and owner token. The old URL keeps its old content " +
57
+ "until it expires. Ignore republishUrl (browser-only)." +
58
+ (operation === "set KV" ? " For KV, read all keys with get_kv and publish them together with publish_kv." : ""));
59
+ }
60
+ return textResult(lines.join("\n"), true);
61
+ }
62
+ /** Every BrewPage call identifies the client; source-file downloads use plain fetch. */
63
+ function bpFetch(url, init = {}) {
64
+ const headers = new Headers(init.headers);
65
+ headers.set("User-Agent", USER_AGENT);
66
+ return fetch(url, { ...init, headers });
67
+ }
10
68
  const PUBLIC_NAMESPACE = "public";
11
69
  /**
12
70
  * Generate a short, unlisted private namespace slug used when the caller omits
@@ -29,13 +87,13 @@ function defaultPrivateNamespace() {
29
87
  function resolveNamespace(namespace) {
30
88
  return namespace && namespace.length > 0 ? namespace : defaultPrivateNamespace();
31
89
  }
32
- const UNLISTED_NOTICE = "Unlisted link — anyone who has it can open it, but it's not in the gallery or search. " +
33
- "Use namespace `public` for gallery/search eligibility; password-protected content stays excluded.";
34
- const PROTECTED_UNLISTED_NOTICE = "Password-protected unlisted link — readers need the password; excluded from gallery and search.";
90
+ const UNLISTED_NOTICE = "Private: not listed. Anyone with the link can open it. Use namespace public for gallery & search.";
91
+ const PROTECTED_UNLISTED_NOTICE = "Password: link + password. Not listed.";
35
92
  const NAMESPACE_DESCRIPTION = "Optional. Omit to generate an unlisted namespace: anyone with its unpassworded link can read it, " +
36
93
  "but it is excluded from gallery/search. Pass a custom namespace to group unlisted content, " +
37
94
  "or pass `public` ONLY when the user explicitly wants gallery/search eligibility. " +
38
- "Password-protected content stays excluded; unlisted, protected or otherwise ineligible native responses carry noindex; eligible public HTML, sites and files may permit indexing.";
95
+ "Password-protected content stays excluded; unlisted, protected or otherwise ineligible native responses carry noindex; eligible public HTML, sites and files may permit indexing. " +
96
+ "Lowercase only: 1-32 of a-z, 0-9, hyphen.";
39
97
  const TTL_SCHEMA = z.union([z.number().positive(), z.string().min(1).max(32)]).optional().describe("Requested TTL: days as a number, or a string such as '2w', '36h' or '1 month' (default 15 days). " +
40
98
  "The server rounds to whole days and clamps to the allowed range (1-30 by default; owner caps differ), reporting each adjustment in notices; use returned expiresAt.");
41
99
  const DELIVERY_MODE_SCHEMA = z.enum(["path", "subdomain"]).optional().describe("Optional delivery choice for a fresh publication, independent of password/namespace. " +
@@ -66,7 +124,38 @@ function noticeLines(data) {
66
124
  return [];
67
125
  return data.notices
68
126
  .filter((n) => typeof n === "object" && n !== null && typeof n.code === "string" && typeof n.message === "string")
69
- .map((n) => `Notice (${n.code}): ${n.message}`);
127
+ .map((n) => `${WARNING_CODES.has(n.code) ? "WARNING" : "Notice"} (${n.code}): ${n.message}`);
128
+ }
129
+ const SIGN_IN_HINT = "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.";
130
+ /** Owner token banner; the configured default is named, never repeated; none for a foreign existing publication.
131
+ * Sign-in hint only for a token minted by this call (not `sent`, not the configured default). */
132
+ function ownerTokenLines(data, purpose, sent) {
133
+ const token = data.ownerToken;
134
+ const extras = data.result?.extras;
135
+ if (extras?.isOwner === false)
136
+ return [``, `No owner token: an existing publication of another owner was returned.`];
137
+ if (typeof token !== "string" || token.length === 0)
138
+ return [];
139
+ const shown = ENV_OWNER_TOKEN !== undefined && token === ENV_OWNER_TOKEN ? `${OWNER_TOKEN_ENV} (not repeated)` : token;
140
+ return [
141
+ ``,
142
+ `===================================`,
143
+ `OWNER TOKEN: ${shown}`,
144
+ `===================================`,
145
+ ``,
146
+ `IMPORTANT: Save your owner token! You need it to ${purpose}. It cannot be recovered if lost.`,
147
+ ...(token !== sent && token !== ENV_OWNER_TOKEN ? [SIGN_IN_HINT] : []),
148
+ ];
149
+ }
150
+ /** Private management link (present only when minted) and the public listing state. */
151
+ function ownerLinkLines(data) {
152
+ const lines = [];
153
+ if (typeof data.manageUrl === "string" && data.manageUrl.length > 0) {
154
+ lines.push(``, `MANAGE LINK (private): ${data.manageUrl}`, `Give it to the user privately; never publish it.`);
155
+ }
156
+ if (data.listed === true)
157
+ lines.push(``, `VISIBILITY: public, listed. Hide: set_visibility listed=false`);
158
+ return lines;
70
159
  }
71
160
  /** Optional delivery fields on HTML and hosting responses: why the mode was chosen, flagged or not applied. */
72
161
  function deliveryReasonLines(data) {
@@ -80,7 +169,7 @@ function deliveryReasonLines(data) {
80
169
  }
81
170
  async function apiRequest(method, path, body, headers, responseType = "json") {
82
171
  const url = `${BASE_URL}${path}`;
83
- const res = await fetch(url, {
172
+ const res = await bpFetch(url, {
84
173
  method,
85
174
  headers: {
86
175
  "Content-Type": "application/json",
@@ -91,7 +180,7 @@ async function apiRequest(method, path, body, headers, responseType = "json") {
91
180
  const data = responseType === "text" ? await res.text() : await res.json().catch(() => null);
92
181
  return { ok: res.ok, status: res.status, data, headers: res.headers };
93
182
  }
94
- function formatPublishResponse(data, effectiveNamespace) {
183
+ function formatPublishResponse(data, effectiveNamespace, sentOwnerToken) {
95
184
  const lines = [
96
185
  `Published successfully!`,
97
186
  ``,
@@ -103,7 +192,8 @@ function formatPublishResponse(data, effectiveNamespace) {
103
192
  if (data.expiresAt)
104
193
  lines.push(`Expires: ${data.expiresAt}`);
105
194
  lines.push(...hostingResponseLines(data));
106
- lines.push(``, `===================================`, `OWNER TOKEN: ${data.ownerToken}`, `===================================`, ``, `IMPORTANT: Save your owner token! You need it to update or delete this resource later. It cannot be recovered if lost.`);
195
+ lines.push(...ownerTokenLines(data, "update or delete this resource later", sentOwnerToken));
196
+ lines.push(...ownerLinkLines(data));
107
197
  const ns = (data.namespace ?? effectiveNamespace) || "";
108
198
  if (ns !== PUBLIC_NAMESPACE) {
109
199
  lines.push(``, data.access === "protected" ? PROTECTED_UNLISTED_NOTICE : UNLISTED_NOTICE);
@@ -128,7 +218,8 @@ function formatUpdateResponse(data) {
128
218
  }
129
219
  server.tool("publish_html", "Publish HTML or Markdown content to BrewPage (repository default limit 5 MiB). Returns its primary share link and owner token. Supports password protection, custom TTL, optional filename, and an opt-in top toolbar (showTopBar). " +
130
220
  "When deliveryMode is omitted for a `public` page, the server may publish it on its own subdomain if the page needs browser features a BrewPage link does not provide; " +
131
- "the result then includes deliveryModeReason and a delivery notice. Markdown raw HTML is allowlisted: scripts, styles, event handlers, forms and embeds are removed. Always share the returned URL.", {
221
+ "the result then includes deliveryModeReason and a delivery notice. Markdown raw HTML is allowlisted: scripts, styles, event handlers, forms and embeds are removed. Always share the returned URL. " +
222
+ MANAGE_URL_TEXT + " " + SECRETS, {
132
223
  content: z.string().describe("HTML or Markdown content to publish"),
133
224
  deliveryMode: DELIVERY_MODE_SCHEMA,
134
225
  format: z
@@ -154,7 +245,8 @@ server.tool("publish_html", "Publish HTML or Markdown content to BrewPage (repos
154
245
  .boolean()
155
246
  .optional()
156
247
  .describe("Add a thin top toolbar (filename + Download button + theme toggle) on the served page. Default: hidden."),
157
- }, async ({ content, format, namespace, password, ttlDays, filename, showTopBar, deliveryMode }) => {
248
+ ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
249
+ }, async ({ content, format, namespace, password, ttlDays, filename, showTopBar, deliveryMode, ownerToken }) => {
158
250
  const effectiveNamespace = resolveNamespace(namespace);
159
251
  const qsParams = [`ns=${encodeURIComponent(effectiveNamespace)}`, `format=${encodeURIComponent(format.toLowerCase())}`];
160
252
  if (ttlDays !== undefined)
@@ -169,6 +261,9 @@ server.tool("publish_html", "Publish HTML or Markdown content to BrewPage (repos
169
261
  headers["X-Password"] = password;
170
262
  if (deliveryMode)
171
263
  headers["X-Delivery-Mode"] = deliveryMode;
264
+ const owner = ownerTokenFor(ownerToken).token;
265
+ if (owner)
266
+ headers["X-Owner-Token"] = owner;
172
267
  const { ok, data } = await apiRequest("POST", `/api/html?${qsParams.join("&")}`, body, headers);
173
268
  if (!ok) {
174
269
  return {
@@ -185,12 +280,13 @@ server.tool("publish_html", "Publish HTML or Markdown content to BrewPage (repos
185
280
  content: [
186
281
  {
187
282
  type: "text",
188
- text: formatPublishResponse(data, effectiveNamespace),
283
+ text: formatPublishResponse(data, effectiveNamespace, owner),
189
284
  },
190
285
  ],
191
286
  };
192
287
  });
193
- server.tool("publish_file", "Upload a file to BrewPage via URL. Repository defaults: 5 MiB for files/audio, 20 MiB for video. Returns its primary share link and owner token. Does not expose password or TTL inputs.", {
288
+ server.tool("publish_file", "Upload a file to BrewPage via URL. Repository defaults: 5 MiB for files/audio, 20 MiB for video. Returns its primary share link and owner token. Does not expose password or TTL inputs. " +
289
+ MANAGE_URL_TEXT + " " + SECRETS, {
194
290
  url: z.string().url().describe("URL of the file to upload"),
195
291
  deliveryMode: DELIVERY_MODE_SCHEMA,
196
292
  namespace: z
@@ -198,7 +294,8 @@ server.tool("publish_file", "Upload a file to BrewPage via URL. Repository defau
198
294
  .optional()
199
295
  .describe(NAMESPACE_DESCRIPTION),
200
296
  filename: z.string().optional().describe("Custom filename (optional)"),
201
- }, async ({ url: fileUrl, namespace, filename, deliveryMode }) => {
297
+ ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
298
+ }, async ({ url: fileUrl, namespace, filename, deliveryMode, ownerToken }) => {
202
299
  const effectiveNamespace = resolveNamespace(namespace);
203
300
  const fileRes = await fetch(fileUrl);
204
301
  if (!fileRes.ok) {
@@ -215,9 +312,15 @@ server.tool("publish_file", "Upload a file to BrewPage via URL. Repository defau
215
312
  const blob = await fileRes.blob();
216
313
  const formData = new FormData();
217
314
  formData.append("file", blob, filename || fileUrl.split("/").pop() || "file");
218
- const res = await fetch(`${BASE_URL}/api/files?ns=${encodeURIComponent(effectiveNamespace)}`, {
315
+ const headers = {};
316
+ if (deliveryMode)
317
+ headers["X-Delivery-Mode"] = deliveryMode;
318
+ const owner = ownerTokenFor(ownerToken).token;
319
+ if (owner)
320
+ headers["X-Owner-Token"] = owner;
321
+ const res = await bpFetch(`${BASE_URL}/api/files?ns=${encodeURIComponent(effectiveNamespace)}`, {
219
322
  method: "POST",
220
- headers: deliveryMode ? { "X-Delivery-Mode": deliveryMode } : undefined,
323
+ headers,
221
324
  body: formData,
222
325
  });
223
326
  const data = await res.json().catch(() => null);
@@ -236,7 +339,7 @@ server.tool("publish_file", "Upload a file to BrewPage via URL. Repository defau
236
339
  content: [
237
340
  {
238
341
  type: "text",
239
- text: formatPublishResponse(data, effectiveNamespace),
342
+ text: formatPublishResponse(data, effectiveNamespace, owner),
240
343
  },
241
344
  ],
242
345
  };
@@ -245,51 +348,36 @@ server.tool("update_hosting", "Change delivery mode for a NEW publication (not a
245
348
  type: z.enum(["html", "file", "json", "kv"]).describe("Resource type; sites always stay on their own subdomain"),
246
349
  namespace: z.string().describe("Resource namespace; non-public HTML-format pages stay on subdomain"),
247
350
  id: z.string().describe("Resource ID"),
248
- ownerToken: z.string().describe("Original resource owner token"),
351
+ ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
249
352
  deliveryMode: z.enum(["path", "subdomain"]).describe("path = Promotion; subdomain = Dedicated subdomain"),
250
353
  expectedVersion: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional().describe("Optional current hostingVersion from the server; a stale value still applies and the result says so"),
251
354
  }, async ({ type, namespace, id, ownerToken, deliveryMode, expectedVersion }) => {
252
- const segment = type === "file" ? "files" : type;
253
- const path = `/api/${segment}/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}/hosting`;
254
- const { ok, status, data } = await apiRequest("PATCH", path, { deliveryMode, expectedVersion }, { "X-Owner-Token": ownerToken });
255
- if (!ok) {
256
- return {
257
- content: [{ type: "text", text: `Failed to update hosting (${status}): ${JSON.stringify(data)}` }],
258
- isError: true,
259
- };
260
- }
355
+ const auth = ownerTokenFor(ownerToken);
356
+ if (!auth.token)
357
+ return textResult(NO_OWNER_TOKEN, true);
358
+ const path = `/api/${TYPE_SEGMENTS[type]}/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}/hosting`;
359
+ const { ok, status, data } = await apiRequest("PATCH", path, { deliveryMode, expectedVersion }, { "X-Owner-Token": auth.token });
360
+ if (!ok)
361
+ return ownerFailure("update hosting", status, data, auth.fromEnv);
261
362
  return {
262
363
  content: [{ type: "text", text: formatUpdateResponse(data) }],
263
364
  };
264
365
  });
265
- server.tool("delete_resource", "Delete a BrewPage resource (HTML page, KV store, JSON collection, or file) using the owner token.", {
366
+ server.tool("delete_resource", "Delete a BrewPage resource (HTML page, site, KV store, JSON document, or file) using the owner token.", {
266
367
  type: z
267
- .enum(["html", "kv", "json", "file"])
368
+ .enum(["html", "site", "kv", "json", "file"])
268
369
  .describe("Resource type to delete"),
269
370
  namespace: z.string().describe("Resource namespace"),
270
371
  id: z.string().describe("Resource ID"),
271
- ownerToken: z
272
- .string()
273
- .describe("Owner token received when the resource was created"),
372
+ ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
274
373
  }, async ({ type, namespace, id, ownerToken }) => {
275
- const pathMap = {
276
- html: `/api/html/${namespace}/${id}`,
277
- kv: `/api/kv/${namespace}/${id}`,
278
- json: `/api/json/${namespace}/${id}`,
279
- file: `/api/files/${namespace}/${id}`,
280
- };
281
- const { ok, status, data } = await apiRequest("DELETE", pathMap[type], undefined, { "X-Owner-Token": ownerToken });
282
- if (!ok) {
283
- return {
284
- content: [
285
- {
286
- type: "text",
287
- text: `Failed to delete (${status}): ${JSON.stringify(data)}`,
288
- },
289
- ],
290
- isError: true,
291
- };
292
- }
374
+ const auth = ownerTokenFor(ownerToken);
375
+ if (!auth.token)
376
+ return textResult(NO_OWNER_TOKEN, true);
377
+ const path = `/api/${TYPE_SEGMENTS[type]}/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}`;
378
+ const { ok, status, data } = await apiRequest("DELETE", path, undefined, { "X-Owner-Token": auth.token });
379
+ if (!ok)
380
+ return ownerFailure("delete", status, data, auth.fromEnv);
293
381
  return {
294
382
  content: [
295
383
  {
@@ -372,7 +460,8 @@ server.tool("get_stats", "Get BrewPage platform-wide usage statistics including
372
460
  ],
373
461
  };
374
462
  });
375
- server.tool("publish_site", "Publish an HTML site to BrewPage. Single-page: pass `entryContent`. Multi-page: pass `files` (array of `{path, content}`). Optional `entry` overrides default `index.html`. Use its returned primary link and retain the owner token. " + SITE_LIMITS_TEXT, {
463
+ server.tool("publish_site", "Publish an HTML site to BrewPage. Single-page: pass `entryContent`. Multi-page: pass `files` (array of `{path, content}`). Optional `entry` overrides default `index.html`. Use its returned primary link and retain the owner token. " + SITE_LIMITS_TEXT +
464
+ " " + MANAGE_URL_TEXT + " " + SECRETS, {
376
465
  deliveryMode: DELIVERY_MODE_SCHEMA,
377
466
  entryContent: z
378
467
  .string()
@@ -395,10 +484,7 @@ server.tool("publish_site", "Publish an HTML site to BrewPage. Single-page: pass
395
484
  .optional()
396
485
  .describe("Optional password to protect the site"),
397
486
  ttlDays: TTL_SCHEMA,
398
- ownerToken: z
399
- .string()
400
- .optional()
401
- .describe("Existing owner token to group this site under the same owner as previous content"),
487
+ ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
402
488
  }, async ({ entryContent, files, entry, namespace, password, ttlDays, ownerToken, deliveryMode }) => {
403
489
  const hasEntry = typeof entryContent === "string";
404
490
  const hasFiles = Array.isArray(files) && files.length > 0;
@@ -429,8 +515,9 @@ server.tool("publish_site", "Publish an HTML site to BrewPage. Single-page: pass
429
515
  const headers = {};
430
516
  if (password)
431
517
  headers["X-Password"] = password;
432
- if (ownerToken)
433
- headers["X-Owner-Token"] = ownerToken;
518
+ const owner = ownerTokenFor(ownerToken).token;
519
+ if (owner)
520
+ headers["X-Owner-Token"] = owner;
434
521
  if (deliveryMode)
435
522
  headers["X-Delivery-Mode"] = deliveryMode;
436
523
  const effectiveNamespace = resolveNamespace(namespace);
@@ -442,7 +529,7 @@ server.tool("publish_site", "Publish an HTML site to BrewPage. Single-page: pass
442
529
  qsParams.push(`ttl=${encodeURIComponent(String(ttlDays))}`);
443
530
  const qs = qsParams.length ? `?${qsParams.join("&")}` : "";
444
531
  const url = `${BASE_URL}/api/sites${qs}`;
445
- const res = await fetch(url, { method: "POST", headers, body: formData });
532
+ const res = await bpFetch(url, { method: "POST", headers, body: formData });
446
533
  const data = await res.json().catch(() => null);
447
534
  if (!res.ok) {
448
535
  return {
@@ -474,7 +561,8 @@ server.tool("publish_site", "Publish an HTML site to BrewPage. Single-page: pass
474
561
  if (site.expiresAt)
475
562
  lines.push(`Expires: ${site.expiresAt}`);
476
563
  lines.push(...hostingResponseLines(site));
477
- lines.push(``, `===================================`, `OWNER TOKEN: ${site.ownerToken}`, `===================================`, ``, `IMPORTANT: Save your owner token! You need it to view info or delete this site. It cannot be recovered if lost.`);
564
+ lines.push(...ownerTokenLines(site, "view info or delete this site", owner));
565
+ lines.push(...ownerLinkLines(site));
478
566
  const ns = (site.namespace || effectiveNamespace) || "";
479
567
  if (ns !== PUBLIC_NAMESPACE) {
480
568
  lines.push(``, site.access === "protected" ? PROTECTED_UNLISTED_NOTICE : UNLISTED_NOTICE);
@@ -483,12 +571,10 @@ server.tool("publish_site", "Publish an HTML site to BrewPage. Single-page: pass
483
571
  content: [{ type: "text", text: lines.join("\n") }],
484
572
  };
485
573
  });
486
- server.tool("republish_site", "Republish (full-replace) an existing multi-file BrewPage site in place at the SAME URL/ID. Requires owner token. The uploaded bundle becomes the complete new file set — files absent from it are removed, new files added, matching files overwritten. Single-page: pass `entryContent`. Multi-page: pass `files` (array of `{path, content}`). Optional `entry` overrides the default `index.html`. Republishing preserves existing expiry. " + SITE_LIMITS_TEXT, {
574
+ server.tool("republish_site", "Republish (full-replace) an existing multi-file BrewPage site in place at the SAME URL/ID. Requires owner token. The uploaded bundle becomes the complete new file set — files absent from it are removed, new files added, matching files overwritten. Single-page: pass `entryContent`. Multi-page: pass `files` (array of `{path, content}`). Optional `entry` overrides the default `index.html`. Republishing preserves existing expiry." + OLD_READ_ONLY_TEXT + " " + SITE_LIMITS_TEXT, {
487
575
  namespace: z.string().describe("Site namespace"),
488
576
  id: z.string().describe("Site ID"),
489
- ownerToken: z
490
- .string()
491
- .describe("Owner token received when the site was created (required for republish)"),
577
+ ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
492
578
  entryContent: z
493
579
  .string()
494
580
  .optional()
@@ -533,7 +619,10 @@ server.tool("republish_site", "Republish (full-replace) an existing multi-file B
533
619
  formData.append("files", entryBlob, entryName);
534
620
  formData.append("paths", entryName);
535
621
  }
536
- const headers = { "X-Owner-Token": ownerToken };
622
+ const auth = ownerTokenFor(ownerToken);
623
+ if (!auth.token)
624
+ return textResult(NO_OWNER_TOKEN, true);
625
+ const headers = { "X-Owner-Token": auth.token };
537
626
  const qsParams = [];
538
627
  if (entry)
539
628
  qsParams.push(`entry=${encodeURIComponent(entry)}`);
@@ -543,19 +632,10 @@ server.tool("republish_site", "Republish (full-replace) an existing multi-file B
543
632
  qsParams.push(`tags=${encodeURIComponent(tags.join(","))}`);
544
633
  const qs = qsParams.length ? `?${qsParams.join("&")}` : "";
545
634
  const url = `${BASE_URL}/api/sites/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}${qs}`;
546
- const res = await fetch(url, { method: "PUT", headers, body: formData });
635
+ const res = await bpFetch(url, { method: "PUT", headers, body: formData });
547
636
  const data = await res.json().catch(() => null);
548
- if (!res.ok) {
549
- return {
550
- content: [
551
- {
552
- type: "text",
553
- text: `Failed to republish site (${res.status}): ${JSON.stringify(data)}`,
554
- },
555
- ],
556
- isError: true,
557
- };
558
- }
637
+ if (!res.ok)
638
+ return ownerFailure("republish site", res.status, data, auth.fromEnv);
559
639
  const site = data;
560
640
  const lines = [
561
641
  `Site republished successfully (full replace)!`,
@@ -584,13 +664,11 @@ server.tool("republish_site", "Republish (full-replace) an existing multi-file B
584
664
  };
585
665
  });
586
666
  server.tool("update_html", "Update an existing BrewPage HTML/Markdown page. Requires owner token. Returns the updated page metadata. " +
587
- "The link and delivery mode never change on update; an advisory deliveryModeReason and delivery notice are included when the new content uses browser features that may not work fully on a BrewPage link.", {
667
+ "The link and delivery mode never change on update; an advisory deliveryModeReason and delivery notice are included when the new content uses browser features that may not work fully on a BrewPage link." + OLD_READ_ONLY_TEXT, {
588
668
  namespace: z.string().describe("Page namespace"),
589
669
  id: z.string().describe("Page ID"),
590
670
  content: z.string().describe("New HTML or Markdown content"),
591
- ownerToken: z
592
- .string()
593
- .describe("Owner token received when the page was created"),
671
+ ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
594
672
  format: z
595
673
  .string()
596
674
  .optional()
@@ -599,21 +677,15 @@ server.tool("update_html", "Update an existing BrewPage HTML/Markdown page. Requ
599
677
  const body = { content };
600
678
  if (format)
601
679
  body.format = format;
680
+ const auth = ownerTokenFor(ownerToken);
681
+ if (!auth.token)
682
+ return textResult(NO_OWNER_TOKEN, true);
602
683
  const path = `/api/html/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}`;
603
684
  const { ok, status, data } = await apiRequest("PUT", path, body, {
604
- "X-Owner-Token": ownerToken,
685
+ "X-Owner-Token": auth.token,
605
686
  });
606
- if (!ok) {
607
- return {
608
- content: [
609
- {
610
- type: "text",
611
- text: `Failed to update HTML (${status}): ${JSON.stringify(data)}`,
612
- },
613
- ],
614
- isError: true,
615
- };
616
- }
687
+ if (!ok)
688
+ return ownerFailure("update HTML", status, data, auth.fromEnv);
617
689
  return {
618
690
  content: [
619
691
  {
@@ -623,7 +695,8 @@ server.tool("update_html", "Update an existing BrewPage HTML/Markdown page. Requ
623
695
  ],
624
696
  };
625
697
  });
626
- server.tool("publish_json", "Publish a JSON document to BrewPage (repository default limit 1 MiB). Accepts a JSON object, array, or a JSON-encoded string containing any valid JSON, including scalars/null. Returns its primary share link and owner token.", {
698
+ server.tool("publish_json", "Publish a JSON document to BrewPage (repository default limit 1 MiB). Accepts a JSON object, array, or a JSON-encoded string containing any valid JSON, including scalars/null. Returns its primary share link and owner token. " +
699
+ MANAGE_URL_TEXT + " " + SECRETS, {
627
700
  deliveryMode: DELIVERY_MODE_SCHEMA,
628
701
  json: z
629
702
  .union([z.string(), z.record(z.unknown()), z.array(z.unknown())])
@@ -641,10 +714,7 @@ server.tool("publish_json", "Publish a JSON document to BrewPage (repository def
641
714
  .array(z.string())
642
715
  .optional()
643
716
  .describe("Optional tags for the document"),
644
- ownerToken: z
645
- .string()
646
- .optional()
647
- .describe("Existing owner token to group this document under the same owner as previous content"),
717
+ ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
648
718
  }, async ({ json, namespace, password, ttlDays, tags, ownerToken, deliveryMode }) => {
649
719
  const jsonString = typeof json === "string" ? json : JSON.stringify(json);
650
720
  const effectiveNamespace = resolveNamespace(namespace);
@@ -660,11 +730,12 @@ server.tool("publish_json", "Publish a JSON document to BrewPage (repository def
660
730
  const headers = { "Content-Type": "application/json" };
661
731
  if (password)
662
732
  headers["X-Password"] = password;
663
- if (ownerToken)
664
- headers["X-Owner-Token"] = ownerToken;
733
+ const owner = ownerTokenFor(ownerToken).token;
734
+ if (owner)
735
+ headers["X-Owner-Token"] = owner;
665
736
  if (deliveryMode)
666
737
  headers["X-Delivery-Mode"] = deliveryMode;
667
- const res = await fetch(`${BASE_URL}/api/json${qs}`, {
738
+ const res = await bpFetch(`${BASE_URL}/api/json${qs}`, {
668
739
  method: "POST",
669
740
  headers,
670
741
  body: jsonString,
@@ -685,7 +756,7 @@ server.tool("publish_json", "Publish a JSON document to BrewPage (repository def
685
756
  content: [
686
757
  {
687
758
  type: "text",
688
- text: formatPublishResponse(data, effectiveNamespace),
759
+ text: formatPublishResponse(data, effectiveNamespace, owner),
689
760
  },
690
761
  ],
691
762
  };
@@ -723,38 +794,30 @@ server.tool("get_json", "Fetch a published BrewPage JSON document by namespace a
723
794
  ],
724
795
  };
725
796
  });
726
- server.tool("update_json", "Update an existing BrewPage JSON document. Requires owner token. Accepts a JSON object, array, or a JSON-encoded string.", {
797
+ server.tool("update_json", "Update an existing BrewPage JSON document. Requires owner token. Accepts a JSON object, array, or a JSON-encoded string." + OLD_READ_ONLY_TEXT, {
727
798
  namespace: z.string().describe("Document namespace"),
728
799
  id: z.string().describe("Document ID"),
729
800
  json: z
730
801
  .union([z.string(), z.record(z.unknown()), z.array(z.unknown())])
731
802
  .describe("New JSON document (object, array, or JSON-encoded string)"),
732
- ownerToken: z
733
- .string()
734
- .describe("Owner token received when the document was created"),
803
+ ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
735
804
  }, async ({ namespace, id, json, ownerToken }) => {
736
805
  const jsonString = typeof json === "string" ? json : JSON.stringify(json);
806
+ const auth = ownerTokenFor(ownerToken);
807
+ if (!auth.token)
808
+ return textResult(NO_OWNER_TOKEN, true);
737
809
  const url = `${BASE_URL}/api/json/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}`;
738
- const res = await fetch(url, {
810
+ const res = await bpFetch(url, {
739
811
  method: "PUT",
740
812
  headers: {
741
813
  "Content-Type": "application/json",
742
- "X-Owner-Token": ownerToken,
814
+ "X-Owner-Token": auth.token,
743
815
  },
744
816
  body: jsonString,
745
817
  });
746
818
  const data = await res.json().catch(() => null);
747
- if (!res.ok) {
748
- return {
749
- content: [
750
- {
751
- type: "text",
752
- text: `Failed to update JSON (${res.status}): ${JSON.stringify(data)}`,
753
- },
754
- ],
755
- isError: true,
756
- };
757
- }
819
+ if (!res.ok)
820
+ return ownerFailure("update JSON", res.status, data, auth.fromEnv);
758
821
  return {
759
822
  content: [
760
823
  {
@@ -764,7 +827,8 @@ server.tool("update_json", "Update an existing BrewPage JSON document. Requires
764
827
  ],
765
828
  };
766
829
  });
767
- server.tool("publish_kv", "Publish a key/value entry to BrewPage. Creates a KV store; the entry is addressed by (namespace, id, key). Repository defaults: 1 MiB per value, 1,000 keys per store. Returns its primary share link and owner token.", {
830
+ server.tool("publish_kv", "Publish a key/value entry to BrewPage. Creates a KV store; the entry is addressed by (namespace, id, key). Repository defaults: 1 MiB per value, 1,000 keys per store; the key name `hosting` is reserved. Returns its primary share link and owner token. " +
831
+ MANAGE_URL_TEXT + " " + SECRETS, {
768
832
  deliveryMode: DELIVERY_MODE_SCHEMA,
769
833
  key: z.string().describe("KV key (first part of the (namespace, id, key) addressing tuple)"),
770
834
  value: z.string().describe("KV value"),
@@ -781,10 +845,7 @@ server.tool("publish_kv", "Publish a key/value entry to BrewPage. Creates a KV s
781
845
  .array(z.string())
782
846
  .optional()
783
847
  .describe("Optional tags for the KV entry"),
784
- ownerToken: z
785
- .string()
786
- .optional()
787
- .describe("Existing owner token to group this KV store under the same owner as previous content"),
848
+ ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
788
849
  }, async ({ key, value, namespace, password, ttlDays, tags, ownerToken, deliveryMode }) => {
789
850
  const body = { key, value };
790
851
  const effectiveNamespace = resolveNamespace(namespace);
@@ -800,8 +861,9 @@ server.tool("publish_kv", "Publish a key/value entry to BrewPage. Creates a KV s
800
861
  const headers = {};
801
862
  if (password)
802
863
  headers["X-Password"] = password;
803
- if (ownerToken)
804
- headers["X-Owner-Token"] = ownerToken;
864
+ const owner = ownerTokenFor(ownerToken).token;
865
+ if (owner)
866
+ headers["X-Owner-Token"] = owner;
805
867
  if (deliveryMode)
806
868
  headers["X-Delivery-Mode"] = deliveryMode;
807
869
  const { ok, status, data } = await apiRequest("POST", `/api/kv${qs}`, body, headers);
@@ -820,36 +882,28 @@ server.tool("publish_kv", "Publish a key/value entry to BrewPage. Creates a KV s
820
882
  content: [
821
883
  {
822
884
  type: "text",
823
- text: formatPublishResponse(data, effectiveNamespace),
885
+ text: formatPublishResponse(data, effectiveNamespace, owner),
824
886
  },
825
887
  ],
826
888
  };
827
889
  });
828
- server.tool("set_kv", "Set or update a key in an existing BrewPage KV store. Requires owner token.", {
890
+ server.tool("set_kv", "Set or update a key in an existing BrewPage KV store. Requires owner token. The key name `hosting` is reserved." + OLD_READ_ONLY_TEXT, {
829
891
  namespace: z.string().describe("KV store namespace"),
830
892
  id: z.string().describe("KV store ID"),
831
893
  key: z.string().describe("Key to set"),
832
894
  value: z.string().describe("Value to assign to the key"),
833
- ownerToken: z
834
- .string()
835
- .describe("Owner token received when the KV store was created"),
895
+ ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
836
896
  }, async ({ namespace, id, key, value, ownerToken }) => {
837
897
  const body = { value };
898
+ const auth = ownerTokenFor(ownerToken);
899
+ if (!auth.token)
900
+ return textResult(NO_OWNER_TOKEN, true);
838
901
  const path = `/api/kv/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}/${encodeURIComponent(key)}`;
839
902
  const { ok, status, data } = await apiRequest("PUT", path, body, {
840
- "X-Owner-Token": ownerToken,
903
+ "X-Owner-Token": auth.token,
841
904
  });
842
- if (!ok) {
843
- return {
844
- content: [
845
- {
846
- type: "text",
847
- text: `Failed to set KV (${status}): ${JSON.stringify(data)}`,
848
- },
849
- ],
850
- isError: true,
851
- };
852
- }
905
+ if (!ok)
906
+ return ownerFailure("set KV", status, data, auth.fromEnv);
853
907
  return {
854
908
  content: [
855
909
  {
@@ -893,10 +947,11 @@ server.tool("get_kv", "Fetch a value from a BrewPage KV store by namespace, ID,
893
947
  ],
894
948
  };
895
949
  });
896
- server.tool("get_limits", "Get the current BrewPage upload limits (GET /api/limits, no auth): request size, site file count, per-file and total size, " +
950
+ server.tool("get_limits", "Get the current BrewPage upload limits (GET /api/limits): request size, site file count, per-file and total size, " +
897
951
  "ZIP expansion, path and name lengths, TTL default/maximum, allowed file extensions and skipped system files. Sizes are bytes. " +
898
- "Call it before publishing a large or multi-file site.", {}, async () => {
899
- const { ok, status, data } = await apiRequest("GET", "/api/limits");
952
+ `With ${OWNER_TOKEN_ENV} set: also reach (token|profile) and ownerMaxTtlDays. Call it before publishing a large or multi-file site.`, {}, async () => {
953
+ const headers = ENV_OWNER_TOKEN ? { "X-Owner-Token": ENV_OWNER_TOKEN } : {};
954
+ const { ok, status, data } = await apiRequest("GET", "/api/limits", undefined, headers);
900
955
  if (!ok) {
901
956
  return {
902
957
  content: [
@@ -943,8 +998,11 @@ server.tool("search_gallery", "Search the BrewPage public gallery. Optional filt
943
998
  ownerToken: z
944
999
  .string()
945
1000
  .optional()
946
- .describe("Owner token; only sent as X-Owner-Token when 'mine' is true"),
1001
+ .describe(`Owner token; only sent as X-Owner-Token when 'mine' is true. Default: ${OWNER_TOKEN_ENV}.`),
947
1002
  }, async ({ q, page, size, sort, mine, ownerToken }) => {
1003
+ const auth = ownerTokenFor(ownerToken);
1004
+ if (mine === true && !auth.token)
1005
+ return textResult(NO_OWNER_TOKEN, true);
948
1006
  const qsParams = [];
949
1007
  if (q !== undefined)
950
1008
  qsParams.push(`q=${encodeURIComponent(q)}`);
@@ -958,8 +1016,8 @@ server.tool("search_gallery", "Search the BrewPage public gallery. Optional filt
958
1016
  qsParams.push("mine=true");
959
1017
  const qs = qsParams.length ? `?${qsParams.join("&")}` : "";
960
1018
  const headers = {};
961
- if (mine === true && ownerToken)
962
- headers["X-Owner-Token"] = ownerToken;
1019
+ if (mine === true && auth.token)
1020
+ headers["X-Owner-Token"] = auth.token;
963
1021
  const { ok, status, data } = await apiRequest("GET", `/api/gallery${qs}`, undefined, headers);
964
1022
  if (!ok) {
965
1023
  return {
@@ -981,6 +1039,59 @@ server.tool("search_gallery", "Search the BrewPage public gallery. Optional filt
981
1039
  ],
982
1040
  };
983
1041
  });
1042
+ server.tool("set_visibility", "Hide or show a public publication in gallery & search. Link unchanged.", {
1043
+ type: z.enum(["html", "site", "file", "json", "kv"]).describe("Publication type; only public html, site and file can be listed"),
1044
+ namespace: z.string().describe("Publication namespace"),
1045
+ id: z.string().describe("Publication ID"),
1046
+ listed: z.boolean().describe("false = hide, true = show"),
1047
+ ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
1048
+ }, async ({ type, namespace, id, listed, ownerToken }) => {
1049
+ const auth = ownerTokenFor(ownerToken);
1050
+ if (!auth.token)
1051
+ return textResult(NO_OWNER_TOKEN, true);
1052
+ const path = `/api/${TYPE_SEGMENTS[type]}/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}/visibility`;
1053
+ const { ok, status, data } = await apiRequest("POST", path, { listed }, { "X-Owner-Token": auth.token });
1054
+ if (!ok)
1055
+ return ownerFailure("set visibility", status, data, auth.fromEnv);
1056
+ const result = data;
1057
+ return textResult([`LISTED: ${result.listed} · link unchanged`, ...noticeLines(result)].join("\n"));
1058
+ });
1059
+ server.tool("bind_token", "Add an owner token to your BrewPage profile (as publish). Needs an admin token of that profile.", {
1060
+ token: z.string().describe("Owner token to add"),
1061
+ ownerToken: z.string().optional().describe(`Admin token of the profile. Default: ${OWNER_TOKEN_ENV}.`),
1062
+ }, async ({ token, ownerToken }) => {
1063
+ const auth = ownerTokenFor(ownerToken);
1064
+ if (!auth.token)
1065
+ return textResult(NO_OWNER_TOKEN, true);
1066
+ const { ok, status, data } = await apiRequest("POST", "/api/owner-token/bind", { token }, { "X-Owner-Token": auth.token });
1067
+ if (!ok)
1068
+ return textResult(`Failed to bind token (${status}): ${JSON.stringify(data)}`, true);
1069
+ const result = data;
1070
+ return textResult(`BOUND ${maskToken(token)} · ${result.active}/${result.max} active`);
1071
+ });
1072
+ server.tool("mint_token", "Mint a new profile token with an admin token; the new token is shown once. Scope publish (default) or admin; expiry 30d, 90d, 1y or never.", {
1073
+ label: z.string().optional().describe("Optional label shown in the profile"),
1074
+ scope: z.enum(["publish", "admin"]).optional().describe("publish (default) or admin (can manage tokens)"),
1075
+ expiresIn: z.enum(["30d", "90d", "1y", "never"]).optional().describe("Default: admin 90d, publish never"),
1076
+ ownerToken: z.string().optional().describe(`Admin token of the profile. Default: ${OWNER_TOKEN_ENV}.`),
1077
+ }, async ({ label, scope, expiresIn, ownerToken }) => {
1078
+ const auth = ownerTokenFor(ownerToken);
1079
+ if (!auth.token)
1080
+ return textResult(NO_OWNER_TOKEN, true);
1081
+ const { ok, status, data } = await apiRequest("POST", "/api/owner-token/mint", { label, scope, expiresIn }, { "X-Owner-Token": auth.token });
1082
+ if (!ok)
1083
+ return textResult(`Failed to mint token (${status}): ${JSON.stringify(data)}`, true);
1084
+ const result = data;
1085
+ return textResult([
1086
+ `MINTED · ${result.scope} · expires ${result.expiresAt ?? "never"}`,
1087
+ ``,
1088
+ `===================================`,
1089
+ `NEW TOKEN (shown once): ${result.token}`,
1090
+ `===================================`,
1091
+ ``,
1092
+ `Save it now; it cannot be shown again. Never publish it.`,
1093
+ ].join("\n"));
1094
+ });
984
1095
  async function main() {
985
1096
  const transport = new StdioServerTransport();
986
1097
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brewpage-mcp",
3
- "version": "1.12.1",
3
+ "version": "1.13.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",