brewpage-mcp 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +88 -34
  2. package/dist/index.js +198 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,14 +7,16 @@ MCP server for [BrewPage](https://brewpage.app) -- publish and manage HTML, KV,
7
7
 
8
8
  ## What is BrewPage
9
9
 
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 a stable HTTPS short URL -- 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 (keeping the same URL), delete the resource, or authenticate list operations across sessions. `brewpage-mcp` exposes this API as fourteen 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.
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 sixteen 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
11
 
12
12
  ## Quick Start
13
13
 
14
14
  ```bash
15
- npx brewpage-mcp
15
+ npx -y brewpage-mcp@1.8.0
16
16
  ```
17
17
 
18
+ Requires Node.js 20 or newer. This starts the stdio server for an MCP client.
19
+
18
20
  ## Installation
19
21
 
20
22
  ### Claude Desktop
@@ -26,7 +28,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
26
28
  "mcpServers": {
27
29
  "brewpage": {
28
30
  "command": "npx",
29
- "args": ["-y", "brewpage-mcp"]
31
+ "args": ["-y", "brewpage-mcp@1.8.0"]
30
32
  }
31
33
  }
32
34
  }
@@ -34,19 +36,14 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
34
36
 
35
37
  ### Claude Code
36
38
 
37
- Add to `~/.claude/settings.json`:
39
+ Register a project-scoped stdio server:
38
40
 
39
- ```json
40
- {
41
- "mcpServers": {
42
- "brewpage": {
43
- "command": "npx",
44
- "args": ["-y", "brewpage-mcp"]
45
- }
46
- }
47
- }
41
+ ```bash
42
+ claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.8.0
48
43
  ```
49
44
 
45
+ 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).
46
+
50
47
  ### Cursor
51
48
 
52
49
  Open **Settings > MCP** and add:
@@ -56,7 +53,7 @@ Open **Settings > MCP** and add:
56
53
  "mcpServers": {
57
54
  "brewpage": {
58
55
  "command": "npx",
59
- "args": ["-y", "brewpage-mcp"]
56
+ "args": ["-y", "brewpage-mcp@1.8.0"]
60
57
  }
61
58
  }
62
59
  }
@@ -70,7 +67,7 @@ Open the Cline MCP settings panel and add:
70
67
  {
71
68
  "brewpage": {
72
69
  "command": "npx",
73
- "args": ["-y", "brewpage-mcp"]
70
+ "args": ["-y", "brewpage-mcp@1.8.0"]
74
71
  }
75
72
  }
76
73
  ```
@@ -78,13 +75,52 @@ Open the Cline MCP settings panel and add:
78
75
  ### Global Install
79
76
 
80
77
  ```bash
81
- npm install -g brewpage-mcp
78
+ npm install -g brewpage-mcp@1.8.0
82
79
  brewpage-mcp
83
80
  ```
84
81
 
85
82
  ## Tools
86
83
 
87
- Fourteen tools are available, grouped by resource: **HTML**, **JSON**, **KV**, **Files**, **Sites**, and **Discovery**. All write operations return an owner token; keep it to modify or delete the resource later. Any update (`PUT`) operation requires the original `ownerToken` -- it is the backend's authorization gate. `password` is optional on reads/creates; if set, it is sent as the `X-Password` header.
84
+ Sixteen 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`.
85
+
86
+ | Tool | Purpose |
87
+ |------|---------|
88
+ | `publish_html` | Publish HTML or Markdown |
89
+ | `update_html` | Replace page content; omitted format preserves stored format |
90
+ | `get_page` | Fetch raw page content |
91
+ | `publish_file` | Fetch a source URL and upload the file |
92
+ | `publish_site` | Publish a single page or complete multi-file site bundle |
93
+ | `republish_site` | Replace the complete site bundle at the same ID/link |
94
+ | `publish_json` | Publish a JSON object, array, or JSON-encoded string |
95
+ | `get_json` | Read a JSON document |
96
+ | `update_json` | Replace a JSON document |
97
+ | `publish_kv` | Create a KV store with an initial entry |
98
+ | `set_kv` | Set or replace a key in an owned KV store |
99
+ | `get_kv` | Read a KV value |
100
+ | `update_hosting` | Change eligible delivery mode with owner token and hosting version |
101
+ | `delete_resource` | Delete HTML, JSON, KV, or file content |
102
+ | `search_gallery` | Browse public content or list owned resources |
103
+ | `get_stats` | Read platform-wide statistics |
104
+
105
+ 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.
106
+
107
+ For a fresh publication, sites and non-public namespaces require Dedicated subdomain (`subdomain`); explicit `path` is rejected. Other public publications default 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.
108
+
109
+ Responses use the server's primary `link` verbatim, including the root slash, and expose `routingCohort`, actual/requested/effective modes, `deliveryModeMatched`, `modeLocked`, `hostingVersion`, access and the trusted-apex `managementLink` when present. Never reconstruct a host from namespace or ID. NEW password links are clean URLs: do not add `?p=`; browser readers unlock through the trusted gateway. Owner tokens and API calls stay on the configured apex API origin.
110
+
111
+ An existing deduplication winner keeps its actual URL, cohort and mode, even when another mode was requested. OLD publications display **Existing link**, with null mode metadata; their legacy links and behavior remain unchanged. Ordinary content updates and site republishing preserve hosting. An eligible NEW public non-site may change mode separately with `update_hosting`.
112
+
113
+ ### Hosting
114
+
115
+ ### `update_hosting`
116
+
117
+ Change an eligible NEW public non-site between Promotion and Dedicated subdomain. Parameters: `type` (`html` | `file` | `json` | `kv`), `namespace`, `id`, `ownerToken`, `deliveryMode` (`path` | `subdomain`), and `expectedVersion` (current server `hostingVersion`, integer ≥1).
118
+
119
+ The tool sends owner-only `PATCH /api/{html|files|json|kv}/{namespace}/{id}/hosting` with `{deliveryMode, expectedVersion}` and `X-Owner-Token`. OLD links, sites and non-public publications are locked. The server returns 403 for invalid owner authority, 400 for locked hosting, and 409 for a stale version; reload current metadata before retrying. A paused real change returns 503. Selecting the current mode returns 200 without incrementing the version. Use the returned primary link after a successful change; the publication host identity is retained through both directions.
120
+
121
+ ```text
122
+ update_hosting(type="html", namespace="public", id="aBcDeFgHiJ", ownerToken="tok_...", deliveryMode="subdomain", expectedVersion=1)
123
+ ```
88
124
 
89
125
  ### HTML
90
126
 
@@ -92,7 +128,7 @@ Fourteen tools are available, grouped by resource: **HTML**, **JSON**, **KV**, *
92
128
 
93
129
  Publish HTML or Markdown content to BrewPage. Returns a public URL and owner token.
94
130
 
95
- Parameters: `content` (string), `format` (`HTML` | `MARKDOWN`, default `HTML`), `namespace` (optional -- **omit to keep the page private/unlisted**: reachable only by its link, not in the gallery, not indexed by search engines; pass `public` only when the user explicitly wants it gallery-listed and search-indexed), `password` (optional), `ttlDays` (1--30, default 15), `filename` (optional, used as title fallback), `showTopBar` (optional boolean -- adds a toolbar with filename, Download button, and theme toggle).
131
+ Parameters: `content` (string), `format` (`HTML` | `MARKDOWN`, default `HTML`), `namespace` (optional -- **omit for unlisted content**; pass `public` only for gallery/search eligibility, except when password-protected), `password` (optional), `ttlDays` (1--30, default 15), `filename` (optional, used as title fallback), `showTopBar` (optional boolean -- adds a toolbar with filename, Download button, and theme toggle), `deliveryMode` (optional).
96
132
 
97
133
  Example prompt that invokes this tool:
98
134
 
@@ -108,7 +144,7 @@ publish_html(content="<h1>Report</h1>...", format="HTML", ttlDays=15)
108
144
 
109
145
  Update an existing HTML or Markdown page in place, preserving its short URL. Requires the original `ownerToken` returned at creation.
110
146
 
111
- Parameters: `namespace` (string), `id` (string), `content` (string, the new body), `ownerToken` (string), `format` (optional `HTML` | `MARKDOWN` | `code`, defaults to `HTML`).
147
+ Parameters: `namespace` (string), `id` (string), `content` (string, the new body), `ownerToken` (string), `format` (optional HTML/Markdown alias or supported code language such as `json`, `yaml`, or `typescript`; omitted preserves the stored format).
112
148
 
113
149
  Example prompt:
114
150
 
@@ -124,6 +160,8 @@ update_html(namespace="public", id="aBcDeFgHiJ", content="<h1>Updated</h1>...",
124
160
 
125
161
  Fetch the content of a published BrewPage HTML page by namespace and ID.
126
162
 
163
+ Returns the actual response body as text, including authored HTML or JSON code. Hosting metadata and the primary link come only from server response headers; JSON fields inside the publication are never treated as platform metadata. The existing apex HTML API route is retained.
164
+
127
165
  Parameters: `namespace` (string), `id` (string), `password` (optional, if the page is password-protected).
128
166
 
129
167
  Example prompt:
@@ -142,7 +180,7 @@ get_page(namespace="public", id="aBcDeFgHiJ")
142
180
 
143
181
  Upload a file to BrewPage by fetching it from a URL. Returns a public URL and owner token. Supports images, PDFs, video, audio, code files, and archives.
144
182
 
145
- Parameters: `url` (string, the source URL to fetch), `namespace` (optional -- omit to keep the file private/unlisted: reachable only by its link, not in the gallery, not search-indexed; pass `public` only to list and index it), `filename` (optional custom filename).
183
+ Parameters: `url` (string, the source URL to fetch), `namespace` (optional -- omit to keep the file private/unlisted: reachable only by its link, not in the gallery, not search-indexed; pass `public` only for gallery/search eligibility), `filename` (optional custom filename), `deliveryMode` (optional). This tool does not expose base64, password, or TTL inputs.
146
184
 
147
185
  Example prompt:
148
186
 
@@ -160,7 +198,7 @@ publish_file(url="https://example.com/diagram.png")
160
198
 
161
199
  Publish a single-page or multi-file HTML site. Pass `entryContent` for a single page or `files` (array of `{path, content}`) for a multi-file site. Supports password protection, TTL, and owner token grouping.
162
200
 
163
- Parameters: `entryContent` (string, mutually exclusive with `files`), `files` (array of `{path, content}`, mutually exclusive with `entryContent`), `entry` (optional entry file path, default `index.html`), `namespace` (optional -- omit to keep the site private/unlisted: reachable only by its link, not in the gallery, not search-indexed; pass `public` only to list and index it), `password` (optional), `ttlDays` (1--30, default 15), `ownerToken` (optional, groups site under an existing owner).
201
+ Parameters: exactly one of `entryContent` (string) or non-empty `files` (array of `{path, content}`), `entry` (optional entry file path, default `index.html`), `namespace` (optional -- omit for unlisted content; `public` grants gallery eligibility except when password-protected), `password` (optional), `ttlDays` (1--30, default 15), `ownerToken` (optional, groups site under an existing owner), `deliveryMode` (optional; fresh sites require `subdomain`).
164
202
 
165
203
  Example prompt:
166
204
 
@@ -172,13 +210,25 @@ publish_site(files=[{"path": "index.html", "content": "..."}, {"path": "style.cs
172
210
 
173
211
  ---
174
212
 
213
+ ### `republish_site`
214
+
215
+ Replace an owned site's complete file set at the same ID and primary link. Files absent from the new bundle are removed; matching files are overwritten and new files added. Hosting stays unchanged.
216
+
217
+ Parameters: `namespace`, `id`, `ownerToken`, exactly one of `entryContent` or a non-empty `files` array of `{path, content}`, optional `entry` (default `index.html`), `ttlDays` (1--30, default 15), and `tags`.
218
+
219
+ ```text
220
+ republish_site(namespace="public", id="aBcDeFgHiJ", ownerToken="tok_...", files=[{"path":"index.html","content":"<h1>Updated site</h1>"}])
221
+ ```
222
+
223
+ ---
224
+
175
225
  ### JSON
176
226
 
177
227
  ### `publish_json`
178
228
 
179
229
  Publish a JSON document to BrewPage. Returns a public URL and owner token. The body may be passed as a JSON string or as a structured object.
180
230
 
181
- Parameters: `json` (string or object, the JSON payload), `namespace` (optional -- omit to keep the document private/unlisted: reachable only by its link, not in the gallery, not search-indexed; pass `public` only to list and index it), `password` (optional), `ttlDays` (optional, 1--30, default 15), `tags` (optional `string[]`).
231
+ Parameters: `json` (string, object, or array), `namespace` (optional -- omit for unlisted content; `public` grants gallery/search eligibility, except when password-protected), `password` (optional), `ttlDays` (1--30, default 15), `tags` (optional `string[]`), `ownerToken` (optional existing owner), `deliveryMode` (optional).
182
232
 
183
233
  Example prompt:
184
234
 
@@ -210,7 +260,7 @@ get_json(namespace="public", id="aBcDeFgHiJ")
210
260
 
211
261
  Update an existing JSON document in place, preserving its short URL. Requires the original `ownerToken`.
212
262
 
213
- Parameters: `namespace` (string), `id` (string), `json` (string or object, the new payload), `ownerToken` (string).
263
+ Parameters: `namespace` (string), `id` (string), `json` (string, object, or array), `ownerToken` (string).
214
264
 
215
265
  Example prompt:
216
266
 
@@ -228,14 +278,14 @@ update_json(namespace="public", id="aBcDeFgHiJ", json={"mode": "dark", "version"
228
278
 
229
279
  Create a new KV (key/value) entry. Returns a public URL and owner token. The KV entry is addressed by `(namespace, id, key)`; this tool creates the entry and emits the generated `id` along with the owner token.
230
280
 
231
- Parameters: `key` (string), `value` (string), `namespace` (optional -- omit to keep the KV store private/unlisted: reachable only by its link, not in the gallery, not search-indexed; pass `public` only to list and index it), `password` (optional), `ttlDays` (optional, 1--30, default 15), `tags` (optional `string[]`).
281
+ Parameters: `key` (string), `value` (string), `namespace` (optional -- omit for unlisted content; `public` grants gallery/search eligibility, except when password-protected), `password` (optional), `ttlDays` (1--30, default 15), `tags` (optional `string[]`), `ownerToken` (optional existing owner), `deliveryMode` (optional).
232
282
 
233
283
  Example prompt:
234
284
 
235
- > "Store the API key under the label 'staging-token' so I can reuse it next session."
285
+ > "Store the selected theme so I can reuse it next session."
236
286
 
237
287
  ```
238
- publish_kv(key="staging-token", value="abc123")
288
+ publish_kv(key="theme", value="dark")
239
289
  ```
240
290
 
241
291
  ---
@@ -248,10 +298,10 @@ Parameters: `namespace` (string), `id` (string), `key` (string), `value` (string
248
298
 
249
299
  Example prompt:
250
300
 
251
- > "Update the staging-token value to the new secret."
301
+ > "Change the selected theme to light."
252
302
 
253
303
  ```
254
- set_kv(namespace="public", id="aBcDeFgHiJ", key="staging-token", value="def456", ownerToken="tok_...")
304
+ set_kv(namespace="public", id="aBcDeFgHiJ", key="theme", value="light", ownerToken="tok_...")
255
305
  ```
256
306
 
257
307
  ---
@@ -264,10 +314,10 @@ Parameters: `namespace` (string), `id` (string), `key` (string), `password` (opt
264
314
 
265
315
  Example prompt:
266
316
 
267
- > "What did I store under staging-token?"
317
+ > "What theme did I store?"
268
318
 
269
319
  ```
270
- get_kv(namespace="public", id="aBcDeFgHiJ", key="staging-token")
320
+ get_kv(namespace="public", id="aBcDeFgHiJ", key="theme")
271
321
  ```
272
322
 
273
323
  ---
@@ -298,6 +348,8 @@ Delete a BrewPage resource (HTML page, KV store, JSON collection, or file) using
298
348
 
299
349
  Parameters: `type` (`html` | `kv` | `json` | `file`), `namespace` (string), `id` (string), `ownerToken` (string).
300
350
 
351
+ Sites are not supported by this tool; use the REST site deletion endpoint.
352
+
301
353
  Example prompt:
302
354
 
303
355
  > "Delete the page I just published -- namespace public, id aBcDeFgHiJ."
@@ -328,9 +380,11 @@ get_stats()
328
380
  |--------------------|------|-------|
329
381
  | "Publish this HTML so I can share it" | `publish_html` | `format=HTML`; save `ownerToken` |
330
382
  | "Share these meeting notes as a link" | `publish_html` | `format=MARKDOWN`; readable rendered output |
331
- | "Host this AI-generated artifact" | `publish_html` | `namespace` optional; **private/unlisted by default** -- pass `public` to gallery-list + index |
383
+ | "Host this AI-generated artifact" | `publish_html` | `namespace` optional; **private/unlisted by default**; `public` grants gallery/search eligibility except when protected |
332
384
  | "Upload this image / PDF / video" | `publish_file` | Fetches from URL; inline preview on short URL |
333
385
  | "Deploy this static site" | `publish_site` | Pass `files` array or `entryContent` |
386
+ | "Replace the site I published" | `republish_site` | Owner token; complete bundle replaces all existing files |
387
+ | "Give my public page a dedicated address" | `update_hosting` | NEW public non-site; owner token + current hosting version |
334
388
  | "Fix a typo in the page I shared -- same link" | `update_html` | Requires `ownerToken`; URL stays the same |
335
389
  | "Remove the page I published" | `delete_resource` | Requires `ownerToken` from creation |
336
390
  | "Read back the page I published" | `get_page` | Returns raw content for editing |
@@ -348,15 +402,15 @@ get_stats()
348
402
 
349
403
  Every publish response includes an **owner token** -- the only credential that allows updating or deleting your content. **Save it. It cannot be recovered.**
350
404
 
351
- - Reuse it via `X-Owner-Token` on subsequent creates to group resources under one owner.
405
+ - Reuse it with `publish_site`, `publish_json`, or `publish_kv` to group new resources under one owner; REST creates accept `X-Owner-Token`.
352
406
  - Pass it to `delete_resource` to remove content.
353
- - Pass it to `get_page` (not needed for reads, but required for listing your own resources via the REST API).
407
+ - Pass it to `search_gallery` with `mine=true` to list owned resources. `get_page` has no owner-token input; protected reads use `password`.
354
408
 
355
409
  ## Configuration
356
410
 
357
411
  | Variable | Default | Description |
358
412
  |----------|---------|-------------|
359
- | `BREWPAGE_URL` | `https://brewpage.app` | API base URL |
413
+ | `BREWPAGE_URL` | `https://brewpage.app` | Authorized apex API base URL; never a publication host |
360
414
 
361
415
  ## Links
362
416
 
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  const BASE_URL = process.env.BREWPAGE_URL || "https://brewpage.app";
6
6
  const server = new McpServer({
7
7
  name: "brewpage-mcp",
8
- version: "1.6.0",
8
+ version: "1.8.0",
9
9
  });
10
10
  const PUBLIC_NAMESPACE = "public";
11
11
  /**
@@ -30,12 +30,26 @@ function resolveNamespace(namespace) {
30
30
  return namespace && namespace.length > 0 ? namespace : defaultPrivateNamespace();
31
31
  }
32
32
  const UNLISTED_NOTICE = "Unlisted link — anyone who has it can open it, but it's not in the gallery or search. " +
33
- "Publish with namespace `public` to list it in the gallery and have search engines index it.";
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.";
34
35
  const NAMESPACE_DESCRIPTION = "Optional. Omit to keep the content PRIVATE/unlisted (reachable only by its link — " +
35
36
  "not listed in the gallery and not indexed by search engines). Pass a custom namespace " +
36
- "to group private content, or pass `public` ONLY when the user explicitly wants the page " +
37
- "listed in the brewpage.app gallery and indexed by search engines.";
38
- async function apiRequest(method, path, body, headers) {
37
+ "to group private content, or pass `public` ONLY when the user explicitly wants gallery/search " +
38
+ "eligibility. Password-protected content stays excluded from gallery/search.";
39
+ const DELIVERY_MODE_SCHEMA = z.enum(["path", "subdomain"]).optional().describe("Optional delivery choice for a fresh publication, independent of password/namespace. " +
40
+ "Public non-sites default to path (Promotion); fresh sites and non-public content require subdomain. " +
41
+ "An existing dedup winner keeps its actual mode and URL.");
42
+ function hostingResponseLines(data) {
43
+ const fields = ["routingCohort", "deliveryMode", "requestedDeliveryMode", "effectiveDeliveryMode",
44
+ "deliveryModeMatched", "modeLocked", "hostingVersion", "access", "managementLink"];
45
+ const lines = fields.filter((field) => field in data).map((field) => `${field}: ${JSON.stringify(data[field])}`);
46
+ if (data.routingCohort === "old")
47
+ lines.push("Hosting: Existing link");
48
+ if (data.deliveryModeMatched === false)
49
+ lines.push("Requested hosting differs from the existing winner; its URL and mode were preserved.");
50
+ return lines;
51
+ }
52
+ async function apiRequest(method, path, body, headers, responseType = "json") {
39
53
  const url = `${BASE_URL}${path}`;
40
54
  const res = await fetch(url, {
41
55
  method,
@@ -45,24 +59,25 @@ async function apiRequest(method, path, body, headers) {
45
59
  },
46
60
  body: body ? JSON.stringify(body) : undefined,
47
61
  });
48
- const data = await res.json().catch(() => null);
49
- return { ok: res.ok, status: res.status, data };
62
+ const data = responseType === "text" ? await res.text() : await res.json().catch(() => null);
63
+ return { ok: res.ok, status: res.status, data, headers: res.headers };
50
64
  }
51
65
  function formatPublishResponse(data, effectiveNamespace) {
52
66
  const lines = [
53
67
  `Published successfully!`,
54
68
  ``,
55
- `URL: ${data.url || data.link || "N/A"}`,
69
+ `URL: ${data.link || data.url || "N/A"}`,
56
70
  ];
57
71
  if (data.shortUrl)
58
72
  lines.push(`Short URL: ${data.shortUrl}`);
59
73
  lines.push(`Namespace: ${data.namespace || "N/A"}`, `ID: ${data.id || "N/A"}`);
60
74
  if (data.expiresAt)
61
75
  lines.push(`Expires: ${data.expiresAt}`);
76
+ lines.push(...hostingResponseLines(data));
62
77
  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.`);
63
- const ns = (effectiveNamespace ?? data.namespace) || "";
78
+ const ns = (data.namespace ?? effectiveNamespace) || "";
64
79
  if (ns !== PUBLIC_NAMESPACE) {
65
- lines.push(``, UNLISTED_NOTICE);
80
+ lines.push(``, data.access === "protected" ? PROTECTED_UNLISTED_NOTICE : UNLISTED_NOTICE);
66
81
  }
67
82
  return lines.join("\n");
68
83
  }
@@ -70,7 +85,7 @@ function formatUpdateResponse(data) {
70
85
  const lines = [
71
86
  `Updated successfully!`,
72
87
  ``,
73
- `URL: ${data.url || data.link || "N/A"}`,
88
+ `URL: ${data.link || data.url || "N/A"}`,
74
89
  ];
75
90
  if (data.shortUrl)
76
91
  lines.push(`Short URL: ${data.shortUrl}`);
@@ -79,10 +94,12 @@ function formatUpdateResponse(data) {
79
94
  lines.push(`Expires: ${data.expiresAt}`);
80
95
  if (data.updatedAt)
81
96
  lines.push(`Updated: ${data.updatedAt}`);
97
+ lines.push(...hostingResponseLines(data));
82
98
  return lines.join("\n");
83
99
  }
84
100
  server.tool("publish_html", "Publish HTML or Markdown content to BrewPage. Returns a public URL and owner token. Supports password protection, custom TTL, optional filename, and an opt-in top toolbar (showTopBar).", {
85
101
  content: z.string().describe("HTML or Markdown content to publish"),
102
+ deliveryMode: DELIVERY_MODE_SCHEMA,
86
103
  format: z
87
104
  .enum(["HTML", "MARKDOWN"])
88
105
  .default("HTML")
@@ -111,19 +128,22 @@ server.tool("publish_html", "Publish HTML or Markdown content to BrewPage. Retur
111
128
  .boolean()
112
129
  .optional()
113
130
  .describe("Add a thin top toolbar (filename + Download button + theme toggle) on the served page. Default: hidden."),
114
- }, async ({ content, format, namespace, password, ttlDays, filename, showTopBar }) => {
131
+ }, async ({ content, format, namespace, password, ttlDays, filename, showTopBar, deliveryMode }) => {
115
132
  const effectiveNamespace = resolveNamespace(namespace);
116
- const body = { content, format };
117
- body.namespace = effectiveNamespace;
118
- if (password)
119
- body.password = password;
120
- if (ttlDays)
121
- body.ttlDays = ttlDays;
133
+ const qsParams = [`ns=${encodeURIComponent(effectiveNamespace)}`, `format=${encodeURIComponent(format.toLowerCase())}`];
134
+ if (ttlDays !== undefined)
135
+ qsParams.push(`ttl=${encodeURIComponent(String(ttlDays))}`);
136
+ const body = { content };
122
137
  if (filename)
123
138
  body.filename = filename;
124
139
  if (showTopBar !== undefined)
125
140
  body.showTopBar = showTopBar;
126
- const { ok, data } = await apiRequest("POST", "/api/html", body);
141
+ const headers = {};
142
+ if (password)
143
+ headers["X-Password"] = password;
144
+ if (deliveryMode)
145
+ headers["X-Delivery-Mode"] = deliveryMode;
146
+ const { ok, data } = await apiRequest("POST", `/api/html?${qsParams.join("&")}`, body, headers);
127
147
  if (!ok) {
128
148
  return {
129
149
  content: [
@@ -146,12 +166,13 @@ server.tool("publish_html", "Publish HTML or Markdown content to BrewPage. Retur
146
166
  });
147
167
  server.tool("publish_file", "Upload a file to BrewPage via URL. Returns a public URL and owner token.", {
148
168
  url: z.string().url().describe("URL of the file to upload"),
169
+ deliveryMode: DELIVERY_MODE_SCHEMA,
149
170
  namespace: z
150
171
  .string()
151
172
  .optional()
152
173
  .describe(NAMESPACE_DESCRIPTION),
153
174
  filename: z.string().optional().describe("Custom filename (optional)"),
154
- }, async ({ url: fileUrl, namespace, filename }) => {
175
+ }, async ({ url: fileUrl, namespace, filename, deliveryMode }) => {
155
176
  const effectiveNamespace = resolveNamespace(namespace);
156
177
  const fileRes = await fetch(fileUrl);
157
178
  if (!fileRes.ok) {
@@ -168,9 +189,9 @@ server.tool("publish_file", "Upload a file to BrewPage via URL. Returns a public
168
189
  const blob = await fileRes.blob();
169
190
  const formData = new FormData();
170
191
  formData.append("file", blob, filename || fileUrl.split("/").pop() || "file");
171
- formData.append("namespace", effectiveNamespace);
172
- const res = await fetch(`${BASE_URL}/api/files`, {
192
+ const res = await fetch(`${BASE_URL}/api/files?ns=${encodeURIComponent(effectiveNamespace)}`, {
173
193
  method: "POST",
194
+ headers: deliveryMode ? { "X-Delivery-Mode": deliveryMode } : undefined,
174
195
  body: formData,
175
196
  });
176
197
  const data = await res.json().catch(() => null);
@@ -194,6 +215,27 @@ server.tool("publish_file", "Upload a file to BrewPage via URL. Returns a public
194
215
  ],
195
216
  };
196
217
  });
218
+ server.tool("update_hosting", "Change delivery mode for a NEW public non-site publication. Requires owner token and current hostingVersion. OLD links and forced subdomain modes are locked; reload after a 409 conflict. Content updates never change hosting.", {
219
+ type: z.enum(["html", "file", "json", "kv"]).describe("Resource type; sites have no hosting-choice endpoint"),
220
+ namespace: z.string().describe("Resource namespace; only public is eligible"),
221
+ id: z.string().describe("Resource ID"),
222
+ ownerToken: z.string().describe("Original resource owner token"),
223
+ deliveryMode: z.enum(["path", "subdomain"]).describe("path = Promotion; subdomain = Dedicated subdomain"),
224
+ expectedVersion: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).describe("Current hostingVersion from the server; required for concurrency control"),
225
+ }, async ({ type, namespace, id, ownerToken, deliveryMode, expectedVersion }) => {
226
+ const segment = type === "file" ? "files" : type;
227
+ const path = `/api/${segment}/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}/hosting`;
228
+ const { ok, status, data } = await apiRequest("PATCH", path, { deliveryMode, expectedVersion }, { "X-Owner-Token": ownerToken });
229
+ if (!ok) {
230
+ return {
231
+ content: [{ type: "text", text: `Failed to update hosting (${status}): ${JSON.stringify(data)}` }],
232
+ isError: true,
233
+ };
234
+ }
235
+ return {
236
+ content: [{ type: "text", text: formatUpdateResponse(data) }],
237
+ };
238
+ });
197
239
  server.tool("delete_resource", "Delete a BrewPage resource (HTML page, KV store, JSON collection, or file) using the owner token.", {
198
240
  type: z
199
241
  .enum(["html", "kv", "json", "file"])
@@ -242,27 +284,35 @@ server.tool("get_page", "Fetch a published BrewPage HTML page content by namespa
242
284
  const headers = {};
243
285
  if (password)
244
286
  headers["X-Password"] = password;
245
- const { ok, status, data } = await apiRequest("GET", `/api/html/${namespace}/${id}`, undefined, headers);
287
+ const { ok, status, data, headers: responseHeaders } = await apiRequest("GET", `/api/html/${namespace}/${id}`, undefined, headers, "text");
246
288
  if (!ok) {
289
+ let errorData = data;
290
+ try {
291
+ errorData = JSON.parse(data);
292
+ }
293
+ catch { }
247
294
  return {
248
295
  content: [
249
296
  {
250
297
  type: "text",
251
- text: `Failed to fetch page (${status}): ${JSON.stringify(data)}`,
298
+ text: `Failed to fetch page (${status}): ${JSON.stringify(errorData)}`,
252
299
  },
253
300
  ],
254
301
  isError: true,
255
302
  };
256
303
  }
257
- const page = data;
304
+ const routingCohort = responseHeaders.get("X-Routing-Cohort");
305
+ const deliveryMode = responseHeaders.get("X-Delivery-Mode");
306
+ const hosting = { ...(routingCohort !== null ? { routingCohort } : {}), ...(deliveryMode !== null ? { deliveryMode } : {}) };
307
+ const hasNewMarkers = (routingCohort !== null && routingCohort !== "old") || deliveryMode !== null;
258
308
  const lines = [
259
309
  `Page: ${namespace}/${id}`,
260
- `URL: ${page.url || `${BASE_URL}/${namespace}/${id}`}`,
261
- `Format: ${page.format || "HTML"}`,
262
- `Created: ${page.createdAt || "N/A"}`,
310
+ `URL: ${responseHeaders.get("X-Canonical-Link") ?? (hasNewMarkers ? "N/A" : `${BASE_URL}/${namespace}/${id}`)}`,
311
+ `Content-Type: ${responseHeaders.get("Content-Type") || "N/A"}`,
312
+ ...hostingResponseLines(hosting),
263
313
  ``,
264
314
  `--- Content ---`,
265
- `${page.content || ""}`,
315
+ data,
266
316
  ];
267
317
  return {
268
318
  content: [{ type: "text", text: lines.join("\n") }],
@@ -297,6 +347,7 @@ server.tool("get_stats", "Get BrewPage platform-wide usage statistics including
297
347
  };
298
348
  });
299
349
  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`.", {
350
+ deliveryMode: DELIVERY_MODE_SCHEMA,
300
351
  entryContent: z
301
352
  .string()
302
353
  .optional()
@@ -327,7 +378,7 @@ server.tool("publish_site", "Publish an HTML site to BrewPage. Single-page: pass
327
378
  .string()
328
379
  .optional()
329
380
  .describe("Existing owner token to group this site under the same owner as previous content"),
330
- }, async ({ entryContent, files, entry, namespace, password, ttlDays, ownerToken }) => {
381
+ }, async ({ entryContent, files, entry, namespace, password, ttlDays, ownerToken, deliveryMode }) => {
331
382
  const hasEntry = typeof entryContent === "string";
332
383
  const hasFiles = Array.isArray(files) && files.length > 0;
333
384
  if (hasEntry === hasFiles) {
@@ -359,6 +410,8 @@ server.tool("publish_site", "Publish an HTML site to BrewPage. Single-page: pass
359
410
  headers["X-Password"] = password;
360
411
  if (ownerToken)
361
412
  headers["X-Owner-Token"] = ownerToken;
413
+ if (deliveryMode)
414
+ headers["X-Delivery-Mode"] = deliveryMode;
362
415
  const effectiveNamespace = resolveNamespace(namespace);
363
416
  const qsParams = [];
364
417
  if (entry)
@@ -399,15 +452,121 @@ server.tool("publish_site", "Publish an HTML site to BrewPage. Single-page: pass
399
452
  lines.push(`Tags: ${site.tags.join(", ")}`);
400
453
  if (site.expiresAt)
401
454
  lines.push(`Expires: ${site.expiresAt}`);
455
+ lines.push(...hostingResponseLines(site));
402
456
  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.`);
403
457
  const ns = (site.namespace || effectiveNamespace) || "";
404
458
  if (ns !== PUBLIC_NAMESPACE) {
405
- lines.push(``, UNLISTED_NOTICE);
459
+ lines.push(``, site.access === "protected" ? PROTECTED_UNLISTED_NOTICE : UNLISTED_NOTICE);
406
460
  }
407
461
  return {
408
462
  content: [{ type: "text", text: lines.join("\n") }],
409
463
  };
410
464
  });
465
+ 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`.", {
466
+ namespace: z.string().describe("Site namespace"),
467
+ id: z.string().describe("Site ID"),
468
+ ownerToken: z
469
+ .string()
470
+ .describe("Owner token received when the site was created (required for republish)"),
471
+ entryContent: z
472
+ .string()
473
+ .optional()
474
+ .describe("HTML content for a single-page site (entry file). Mutually exclusive with `files`."),
475
+ files: z
476
+ .array(z.object({ path: z.string(), content: z.string() }))
477
+ .optional()
478
+ .describe("Multi-file site: array of `{path, content}`. Mutually exclusive with `entryContent`."),
479
+ entry: z
480
+ .string()
481
+ .optional()
482
+ .describe("Entry file path, default index.html"),
483
+ ttlDays: z
484
+ .number()
485
+ .min(1)
486
+ .max(30)
487
+ .optional()
488
+ .describe("Time to live in days (1-30, default: 15)"),
489
+ tags: z
490
+ .array(z.string())
491
+ .optional()
492
+ .describe("Optional tags (replaces the site's existing tags)"),
493
+ }, async ({ namespace, id, ownerToken, entryContent, files, entry, ttlDays, tags }) => {
494
+ const hasEntry = typeof entryContent === "string";
495
+ const hasFiles = Array.isArray(files) && files.length > 0;
496
+ if (hasEntry === hasFiles) {
497
+ return {
498
+ content: [
499
+ {
500
+ type: "text",
501
+ text: "Provide exactly one of `entryContent` (single-page) or `files` (multi-page).",
502
+ },
503
+ ],
504
+ isError: true,
505
+ };
506
+ }
507
+ const formData = new FormData();
508
+ if (hasFiles) {
509
+ for (const f of files) {
510
+ formData.append("files", new Blob([f.content], { type: "text/html" }), f.path);
511
+ formData.append("paths", f.path);
512
+ }
513
+ }
514
+ else {
515
+ const entryBlob = new Blob([entryContent], { type: "text/html" });
516
+ const entryName = entry || "index.html";
517
+ formData.append("files", entryBlob, entryName);
518
+ formData.append("paths", entryName);
519
+ }
520
+ const headers = { "X-Owner-Token": ownerToken };
521
+ const qsParams = [];
522
+ if (entry)
523
+ qsParams.push(`entry=${encodeURIComponent(entry)}`);
524
+ if (ttlDays)
525
+ qsParams.push(`ttl=${encodeURIComponent(String(ttlDays))}`);
526
+ if (tags && tags.length)
527
+ qsParams.push(`tags=${encodeURIComponent(tags.join(","))}`);
528
+ const qs = qsParams.length ? `?${qsParams.join("&")}` : "";
529
+ const url = `${BASE_URL}/api/sites/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}${qs}`;
530
+ const res = await fetch(url, { method: "PUT", headers, body: formData });
531
+ const data = await res.json().catch(() => null);
532
+ if (!res.ok) {
533
+ return {
534
+ content: [
535
+ {
536
+ type: "text",
537
+ text: `Failed to republish site (${res.status}): ${JSON.stringify(data)}`,
538
+ },
539
+ ],
540
+ isError: true,
541
+ };
542
+ }
543
+ const site = data;
544
+ const lines = [
545
+ `Site republished successfully (full replace)!`,
546
+ ``,
547
+ `URL: ${site.link || "N/A"}`,
548
+ `Namespace: ${site.namespace || namespace}`,
549
+ `ID: ${site.id || id}`,
550
+ `Entry file: ${site.entryFile || "index.html"}`,
551
+ `Files: ${site.fileCount || 1}`,
552
+ ];
553
+ if (site.totalSizeBytes !== undefined)
554
+ lines.push(`Size: ${site.totalSizeBytes} bytes`);
555
+ if (site.ownerLink)
556
+ lines.push(`Owner link: ${site.ownerLink}`);
557
+ if (Array.isArray(site.tags) && site.tags.length)
558
+ lines.push(`Tags: ${site.tags.join(", ")}`);
559
+ if (site.expiresAt)
560
+ lines.push(`Expires: ${site.expiresAt}`);
561
+ if (site.result)
562
+ lines.push(`Result: ${site.result}`);
563
+ lines.push(...hostingResponseLines(site));
564
+ if (site.visibilityNotice)
565
+ lines.push(``, String(site.visibilityNotice));
566
+ return {
567
+ content: [{ type: "text", text: lines.join("\n") }],
568
+ };
569
+ });
411
570
  server.tool("update_html", "Update an existing BrewPage HTML/Markdown page. Requires owner token. Returns the updated page metadata.", {
412
571
  namespace: z.string().describe("Page namespace"),
413
572
  id: z.string().describe("Page ID"),
@@ -448,6 +607,7 @@ server.tool("update_html", "Update an existing BrewPage HTML/Markdown page. Requ
448
607
  };
449
608
  });
450
609
  server.tool("publish_json", "Publish a JSON document to BrewPage. Accepts a JSON object, array, or a JSON-encoded string. Returns a public URL and owner token.", {
610
+ deliveryMode: DELIVERY_MODE_SCHEMA,
451
611
  json: z
452
612
  .union([z.string(), z.record(z.unknown()), z.array(z.unknown())])
453
613
  .describe("JSON document to publish (object, array, or JSON-encoded string)"),
@@ -474,7 +634,7 @@ server.tool("publish_json", "Publish a JSON document to BrewPage. Accepts a JSON
474
634
  .string()
475
635
  .optional()
476
636
  .describe("Existing owner token to group this document under the same owner as previous content"),
477
- }, async ({ json, namespace, password, ttlDays, tags, ownerToken }) => {
637
+ }, async ({ json, namespace, password, ttlDays, tags, ownerToken, deliveryMode }) => {
478
638
  const jsonString = typeof json === "string" ? json : JSON.stringify(json);
479
639
  const effectiveNamespace = resolveNamespace(namespace);
480
640
  const qsParams = [];
@@ -491,6 +651,8 @@ server.tool("publish_json", "Publish a JSON document to BrewPage. Accepts a JSON
491
651
  headers["X-Password"] = password;
492
652
  if (ownerToken)
493
653
  headers["X-Owner-Token"] = ownerToken;
654
+ if (deliveryMode)
655
+ headers["X-Delivery-Mode"] = deliveryMode;
494
656
  const res = await fetch(`${BASE_URL}/api/json${qs}`, {
495
657
  method: "POST",
496
658
  headers,
@@ -592,6 +754,7 @@ server.tool("update_json", "Update an existing BrewPage JSON document. Requires
592
754
  };
593
755
  });
594
756
  server.tool("publish_kv", "Publish a key/value entry to BrewPage. Creates a KV store; the entry is addressed by (namespace, id, key). Returns a public URL and owner token.", {
757
+ deliveryMode: DELIVERY_MODE_SCHEMA,
595
758
  key: z.string().describe("KV key (first part of the (namespace, id, key) addressing tuple)"),
596
759
  value: z.string().describe("KV value"),
597
760
  namespace: z
@@ -617,7 +780,7 @@ server.tool("publish_kv", "Publish a key/value entry to BrewPage. Creates a KV s
617
780
  .string()
618
781
  .optional()
619
782
  .describe("Existing owner token to group this KV store under the same owner as previous content"),
620
- }, async ({ key, value, namespace, password, ttlDays, tags, ownerToken }) => {
783
+ }, async ({ key, value, namespace, password, ttlDays, tags, ownerToken, deliveryMode }) => {
621
784
  const body = { key, value };
622
785
  const effectiveNamespace = resolveNamespace(namespace);
623
786
  const qsParams = [];
@@ -634,6 +797,8 @@ server.tool("publish_kv", "Publish a key/value entry to BrewPage. Creates a KV s
634
797
  headers["X-Password"] = password;
635
798
  if (ownerToken)
636
799
  headers["X-Owner-Token"] = ownerToken;
800
+ if (deliveryMode)
801
+ headers["X-Delivery-Mode"] = deliveryMode;
637
802
  const { ok, status, data } = await apiRequest("POST", `/api/kv${qs}`, body, headers);
638
803
  if (!ok) {
639
804
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brewpage-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "mcpName": "io.github.kochetkov-ma/brewpage-mcp",
5
5
  "description": "MCP server for BrewPage — publish and manage HTML, KV, JSON, and file content from AI assistants",
6
6
  "type": "module",