brewpage-mcp 1.13.2 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,15 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { z } from "zod";
5
- const BASE_URL = process.env.BREWPAGE_URL || "https://brewpage.app";
6
- const USER_AGENT = "brewpage-mcp/1.13.2";
7
- const server = new McpServer({
8
- name: "brewpage-mcp",
9
- version: "1.13.2",
10
- });
3
+ import { createServer, DEFAULT_BASE_URL, OWNER_TOKEN_ENV } from "./tools.js";
11
4
  const OWNER_TOKEN_SHAPE = /^[A-Za-z0-9]{32}$/;
12
- const OWNER_TOKEN_ENV = "BREWPAGE_OWNER_TOKEN";
13
5
  /** Optional default owner token for every call; a malformed value is ignored with one stderr line. */
14
6
  const ENV_OWNER_TOKEN = (() => {
15
7
  const raw = process.env[OWNER_TOKEN_ENV]?.trim();
@@ -20,1083 +12,8 @@ const ENV_OWNER_TOKEN = (() => {
20
12
  console.error(`${OWNER_TOKEN_ENV} ignored: expected 32 letters/digits.`);
21
13
  return undefined;
22
14
  })();
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 (made before 5 October 2026) are read-only: 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
- const republishUrl = data.republishUrl;
56
- lines.push("Read-only old publication: cannot be updated. Publish the content anew with the matching " +
57
- "publish tool in the same namespace; give the user the NEW URL and owner token. The old URL keeps its old content " +
58
- "until it expires." + (typeof republishUrl === "string" ? " Ignore republishUrl (browser-only)." : "") +
59
- (operation === "set KV" ? " For KV, read all keys with get_kv and publish them together with publish_kv." : ""));
60
- }
61
- return textResult(lines.join("\n"), true);
62
- }
63
- /** Every BrewPage call identifies the client; source-file downloads use plain fetch. */
64
- function bpFetch(url, init = {}) {
65
- const headers = new Headers(init.headers);
66
- headers.set("User-Agent", USER_AGENT);
67
- return fetch(url, { ...init, headers });
68
- }
69
- const PUBLIC_NAMESPACE = "public";
70
- /** Private namespace slug `priv-<6 lowercase-alphanumeric chars>` sent when the caller omits `namespace`. */
71
- function defaultPrivateNamespace() {
72
- const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
73
- let slug = "";
74
- for (let i = 0; i < 6; i++) {
75
- slug += alphabet[Math.floor(Math.random() * alphabet.length)];
76
- }
77
- return `priv-${slug}`;
78
- }
79
- /** Namespace sent to the API: the caller's value verbatim (including `public`), else a generated private slug. */
80
- function resolveNamespace(namespace) {
81
- return namespace && namespace.length > 0 ? namespace : defaultPrivateNamespace();
82
- }
83
- const UNLISTED_NOTICE = "Private: not listed. Anyone with the link can open it.";
84
- const LISTABLE_HINT = " Use namespace public to list it in the gallery and sitemap.";
85
- const PROTECTED_UNLISTED_NOTICE = "Password: link + password. Not listed.";
86
- const PUBLIC_NAMESPACE_LEAD = "namespace=public: listed in the public gallery and the sitemap, so Google, Bing and AI search can find and index it " +
87
- "and people can discover it. Use it for anything meant to be found or shared: docs, guides, demos, portfolios, reports, landing pages. ";
88
- const PUBLIC_NAMESPACE_TAIL = " Anyone can find and read a public page: never put secrets, tokens or personal data there. " +
89
- "Omit (default): private. Unlisted and not indexed; anyone who has the link can still open it. ";
90
- const NAMESPACE_FORMAT = "Lowercase only: 1-32 of a-z, 0-9, hyphen.";
91
- /** `namespace` for publish_html and publish_file. */
92
- const NAMESPACE_DESCRIPTION = PUBLIC_NAMESPACE_LEAD +
93
- "Public pages are served right on the brewpage.app domain, which gives them the strongest reach in search; " +
94
- "a page whose code needs its own origin (cookies, storage) gets its own subdomain automatically." +
95
- PUBLIC_NAMESPACE_TAIL + NAMESPACE_FORMAT;
96
- const SITE_NAMESPACE_DESCRIPTION = PUBLIC_NAMESPACE_LEAD + "A site always gets its own subdomain." +
97
- PUBLIC_NAMESPACE_TAIL + NAMESPACE_FORMAT;
98
- /** `namespace` for publish_json and publish_kv: data is never listed. */
99
- const DATA_NAMESPACE_DESCRIPTION = "Omit (default): private. namespace=public: the shared public namespace. JSON and KV are never listed in the gallery " +
100
- "or indexed, in any namespace; anyone who has the link can open it. " + NAMESPACE_FORMAT;
101
- 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). " +
102
- "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.");
103
- const DELIVERY_MODE_SCHEMA = z.enum(["path", "subdomain"]).optional().describe("Optional delivery choice for a fresh publication, independent of password/namespace. " +
104
- "Fresh sites and HTML-format pages outside public (including the default unlisted namespace) always use subdomain; an explicit path there still publishes, on subdomain, with a delivery notice. " +
105
- "Everything else in any namespace (Markdown, code/text, files, JSON, KV, public HTML) defaults to path (Promotion); public HTML may automatically choose subdomain for browser features. " +
106
- "When creation returns an existing publication, it keeps its actual mode and URL.");
107
- /** Site upload rules shared by publish_site and republish_site; current values come from get_limits. */
108
- const SITE_LIMITS_TEXT = "Limits (call get_limits for current values): a request is at most 25 MB; a site at most 200 files, 5 MB per file and 20 MB in total; " +
109
- "each path at most 380 characters. System files (.DS_Store, __MACOSX/ and similar) are skipped and never count; files with an unsupported extension " +
110
- "or a too-long path are skipped and listed in notices. Entry: `entry`, else root index.html, else the shallowest HTML file. " +
111
- "Errors: 400 = a site limit exceeded (the message names it and what to do), invalid path or no HTML file; 413 = request over 25 MB, " +
112
- "too many files and form fields, or a file name/path too long; 422 = no servable file left.";
113
- function hostingResponseLines(data) {
114
- const fields = ["routingCohort", "deliveryMode", "requestedDeliveryMode", "effectiveDeliveryMode",
115
- "deliveryModeMatched", "modeLocked", "hostingVersion", "access", "managementLink"];
116
- const lines = fields.filter((field) => field in data).map((field) => `${field}: ${JSON.stringify(data[field])}`);
117
- if (data.routingCohort === "old")
118
- lines.push("Hosting: Existing link");
119
- if (data.deliveryModeMatched === false)
120
- lines.push("Requested hosting differs from the effective mode; use the returned URL.");
121
- lines.push(...deliveryReasonLines(data));
122
- lines.push(...noticeLines(data));
123
- return lines;
124
- }
125
- /** Server `notices` ({code, message}) on publish/update responses: each tolerated-input adjustment, verbatim. */
126
- function noticeLines(data) {
127
- if (!Array.isArray(data.notices))
128
- return [];
129
- return data.notices
130
- .filter((n) => typeof n === "object" && n !== null && typeof n.code === "string" && typeof n.message === "string")
131
- .map((n) => `${WARNING_CODES.has(n.code) ? "WARNING" : "Notice"} (${n.code}): ${n.message}`);
132
- }
133
- 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.";
134
- /** Owner token banner; the configured default is named, never repeated; none for a foreign existing publication.
135
- * Sign-in hint only for a token minted by this call (not `sent`, not the configured default). */
136
- function ownerTokenLines(data, purpose, sent) {
137
- const token = data.ownerToken;
138
- const extras = data.result?.extras;
139
- if (extras?.isOwner === false)
140
- return [``, `No owner token: an existing publication of another owner was returned.`];
141
- if (typeof token !== "string" || token.length === 0)
142
- return [];
143
- const shown = ENV_OWNER_TOKEN !== undefined && token === ENV_OWNER_TOKEN ? `${OWNER_TOKEN_ENV} (not repeated)` : token;
144
- return [
145
- ``,
146
- `===================================`,
147
- `OWNER TOKEN: ${shown}`,
148
- `===================================`,
149
- ``,
150
- `IMPORTANT: Save your owner token! You need it to ${purpose}. It cannot be recovered if lost.`,
151
- ...(token !== sent && token !== ENV_OWNER_TOKEN ? [SIGN_IN_HINT] : []),
152
- ];
153
- }
154
- /** Private management link (present only when minted) and the public listing state. */
155
- function ownerLinkLines(data) {
156
- const lines = [];
157
- if (typeof data.manageUrl === "string" && data.manageUrl.length > 0) {
158
- lines.push(``, `MANAGE LINK (private): ${data.manageUrl}`, `Give it to the user privately; never publish it.`);
159
- }
160
- if (data.listed === true)
161
- lines.push(``, `VISIBILITY: public, listed. Hide: set_visibility listed=false`);
162
- return lines;
163
- }
164
- /** Optional delivery fields on HTML and hosting responses: why the mode was chosen, flagged or not applied. */
165
- function deliveryReasonLines(data) {
166
- const { deliveryModeReason: reason, deliveryNotice: notice } = data;
167
- const lines = [];
168
- if (typeof reason === "string" && reason.length > 0)
169
- lines.push(`deliveryModeReason: ${JSON.stringify(reason)}`);
170
- if (typeof notice === "string" && notice.length > 0)
171
- lines.push(`Delivery notice: ${notice}`);
172
- return lines;
173
- }
174
- async function apiRequest(method, path, body, headers, responseType = "json") {
175
- const url = `${BASE_URL}${path}`;
176
- const res = await bpFetch(url, {
177
- method,
178
- headers: {
179
- "Content-Type": "application/json",
180
- ...headers,
181
- },
182
- body: body ? JSON.stringify(body) : undefined,
183
- });
184
- const data = responseType === "text" ? await res.text() : await res.json().catch(() => null);
185
- return { ok: res.ok, status: res.status, data, headers: res.headers };
186
- }
187
- function formatPublishResponse(data, effectiveNamespace, sentOwnerToken, listable = true) {
188
- const lines = [
189
- `Published successfully!`,
190
- ``,
191
- `URL: ${data.link || data.url || "N/A"}`,
192
- ];
193
- if (data.shortUrl)
194
- lines.push(`Short URL: ${data.shortUrl}`);
195
- lines.push(`Namespace: ${data.namespace || "N/A"}`, `ID: ${data.id || "N/A"}`);
196
- if (data.expiresAt)
197
- lines.push(`Expires: ${data.expiresAt}`);
198
- lines.push(...hostingResponseLines(data));
199
- lines.push(...ownerTokenLines(data, "update or delete this resource later", sentOwnerToken));
200
- lines.push(...ownerLinkLines(data));
201
- const ns = (data.namespace ?? effectiveNamespace) || "";
202
- if (ns !== PUBLIC_NAMESPACE) {
203
- lines.push(``, data.access === "protected" ? PROTECTED_UNLISTED_NOTICE : UNLISTED_NOTICE + (listable ? LISTABLE_HINT : ""));
204
- }
205
- return lines.join("\n");
206
- }
207
- function formatUpdateResponse(data) {
208
- const lines = [
209
- `Updated successfully!`,
210
- ``,
211
- `URL: ${data.link || data.url || "N/A"}`,
212
- ];
213
- if (data.shortUrl)
214
- lines.push(`Short URL: ${data.shortUrl}`);
215
- lines.push(`Namespace: ${data.namespace || "N/A"}`, `ID: ${data.id || "N/A"}`);
216
- if (data.expiresAt)
217
- lines.push(`Expires: ${data.expiresAt}`);
218
- if (data.updatedAt)
219
- lines.push(`Updated: ${data.updatedAt}`);
220
- lines.push(...hostingResponseLines(data));
221
- return lines.join("\n");
222
- }
223
- 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). " +
224
- "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; " +
225
- "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. " +
226
- MANAGE_URL_TEXT + " " + SECRETS, {
227
- content: z.string().describe("HTML or Markdown content to publish"),
228
- deliveryMode: DELIVERY_MODE_SCHEMA,
229
- format: z
230
- .string()
231
- .min(1)
232
- .default("HTML")
233
- .describe("Content format, default HTML. HTML or MARKDOWN; the server also maps aliases (md, text/markdown, file extensions, code languages) and detects unknown values from content, reporting the change in notices."),
234
- namespace: z
235
- .string()
236
- .optional()
237
- .describe(NAMESPACE_DESCRIPTION),
238
- password: z
239
- .string()
240
- .optional()
241
- .describe("Optional password to protect the page"),
242
- ttlDays: TTL_SCHEMA,
243
- filename: z
244
- .string()
245
- .max(200)
246
- .optional()
247
- .describe("Optional original filename (≤200 chars). Used as title fallback when no <title>/<h1>; immutable on update."),
248
- showTopBar: z
249
- .boolean()
250
- .optional()
251
- .describe("Add a thin top toolbar (filename + Download button + theme toggle) on the served page. Default: hidden."),
252
- ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
253
- }, async ({ content, format, namespace, password, ttlDays, filename, showTopBar, deliveryMode, ownerToken }) => {
254
- const effectiveNamespace = resolveNamespace(namespace);
255
- const qsParams = [`ns=${encodeURIComponent(effectiveNamespace)}`, `format=${encodeURIComponent(format.toLowerCase())}`];
256
- if (ttlDays !== undefined)
257
- qsParams.push(`ttl=${encodeURIComponent(String(ttlDays))}`);
258
- const body = { content };
259
- if (filename)
260
- body.filename = filename;
261
- if (showTopBar !== undefined)
262
- body.showTopBar = showTopBar;
263
- const headers = {};
264
- if (password)
265
- headers["X-Password"] = password;
266
- if (deliveryMode)
267
- headers["X-Delivery-Mode"] = deliveryMode;
268
- const owner = ownerTokenFor(ownerToken).token;
269
- if (owner)
270
- headers["X-Owner-Token"] = owner;
271
- const { ok, data } = await apiRequest("POST", `/api/html?${qsParams.join("&")}`, body, headers);
272
- if (!ok) {
273
- return {
274
- content: [
275
- {
276
- type: "text",
277
- text: `Failed to publish: ${JSON.stringify(data)}`,
278
- },
279
- ],
280
- isError: true,
281
- };
282
- }
283
- return {
284
- content: [
285
- {
286
- type: "text",
287
- text: formatPublishResponse(data, effectiveNamespace, owner),
288
- },
289
- ],
290
- };
291
- });
292
- 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. " +
293
- MANAGE_URL_TEXT + " " + SECRETS, {
294
- url: z.string().url().describe("URL of the file to upload"),
295
- deliveryMode: DELIVERY_MODE_SCHEMA,
296
- namespace: z
297
- .string()
298
- .optional()
299
- .describe(NAMESPACE_DESCRIPTION),
300
- filename: z.string().optional().describe("Custom filename (optional)"),
301
- ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
302
- }, async ({ url: fileUrl, namespace, filename, deliveryMode, ownerToken }) => {
303
- const effectiveNamespace = resolveNamespace(namespace);
304
- const fileRes = await fetch(fileUrl);
305
- if (!fileRes.ok) {
306
- return {
307
- content: [
308
- {
309
- type: "text",
310
- text: `Failed to fetch file from URL: ${fileRes.status} ${fileRes.statusText}`,
311
- },
312
- ],
313
- isError: true,
314
- };
315
- }
316
- const blob = await fileRes.blob();
317
- const formData = new FormData();
318
- formData.append("file", blob, filename || fileUrl.split("/").pop() || "file");
319
- const headers = {};
320
- if (deliveryMode)
321
- headers["X-Delivery-Mode"] = deliveryMode;
322
- const owner = ownerTokenFor(ownerToken).token;
323
- if (owner)
324
- headers["X-Owner-Token"] = owner;
325
- const res = await bpFetch(`${BASE_URL}/api/files?ns=${encodeURIComponent(effectiveNamespace)}`, {
326
- method: "POST",
327
- headers,
328
- body: formData,
329
- });
330
- const data = await res.json().catch(() => null);
331
- if (!res.ok) {
332
- return {
333
- content: [
334
- {
335
- type: "text",
336
- text: `Failed to upload file: ${JSON.stringify(data)}`,
337
- },
338
- ],
339
- isError: true,
340
- };
341
- }
342
- return {
343
- content: [
344
- {
345
- type: "text",
346
- text: formatPublishResponse(data, effectiveNamespace, owner),
347
- },
348
- ],
349
- };
350
- });
351
- server.tool("update_hosting", "Change delivery mode for a NEW publication (not a site, not a non-public HTML-format page). Requires owner token; pass the current hostingVersion when known. Mismatches are reported, not rejected: a stale version still applies, and OLD links or forced subdomain modes stay unchanged; the result then carries a delivery notice. Content updates never change hosting.", {
352
- type: z.enum(["html", "file", "json", "kv"]).describe("Resource type; sites always stay on their own subdomain"),
353
- namespace: z.string().describe("Resource namespace; non-public HTML-format pages stay on subdomain"),
354
- id: z.string().describe("Resource ID"),
355
- ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
356
- deliveryMode: z.enum(["path", "subdomain"]).describe("path = Promotion; subdomain = Dedicated subdomain"),
357
- 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"),
358
- }, async ({ type, namespace, id, ownerToken, deliveryMode, expectedVersion }) => {
359
- const auth = ownerTokenFor(ownerToken);
360
- if (!auth.token)
361
- return textResult(NO_OWNER_TOKEN, true);
362
- const path = `/api/${TYPE_SEGMENTS[type]}/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}/hosting`;
363
- const { ok, status, data } = await apiRequest("PATCH", path, { deliveryMode, expectedVersion }, { "X-Owner-Token": auth.token });
364
- if (!ok)
365
- return ownerFailure("update hosting", status, data, auth.fromEnv);
366
- return {
367
- content: [{ type: "text", text: formatUpdateResponse(data) }],
368
- };
369
- });
370
- server.tool("delete_resource", "Delete a BrewPage resource (HTML page, site, KV store, JSON document, or file) using the owner token.", {
371
- type: z
372
- .enum(["html", "site", "kv", "json", "file"])
373
- .describe("Resource type to delete"),
374
- namespace: z.string().describe("Resource namespace"),
375
- id: z.string().describe("Resource ID"),
376
- ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
377
- }, async ({ type, namespace, id, ownerToken }) => {
378
- const auth = ownerTokenFor(ownerToken);
379
- if (!auth.token)
380
- return textResult(NO_OWNER_TOKEN, true);
381
- const path = `/api/${TYPE_SEGMENTS[type]}/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}`;
382
- const { ok, status, data } = await apiRequest("DELETE", path, undefined, { "X-Owner-Token": auth.token });
383
- if (!ok)
384
- return ownerFailure("delete", status, data, auth.fromEnv);
385
- return {
386
- content: [
387
- {
388
- type: "text",
389
- text: `Successfully deleted ${type} resource: ${namespace}/${id}`,
390
- },
391
- ],
392
- };
393
- });
394
- server.tool("get_page", "Fetch stored rendered HTML or format-specific code/text by namespace and ID. This reads API content, not the owner-only original-source endpoint or browser viewer. Protected reads use the password header.", {
395
- namespace: z.string().describe("Page namespace"),
396
- id: z.string().describe("Page ID"),
397
- password: z
398
- .string()
399
- .optional()
400
- .describe("Password if the page is protected"),
401
- }, async ({ namespace, id, password }) => {
402
- const headers = {};
403
- if (password)
404
- headers["X-Password"] = password;
405
- const { ok, status, data, headers: responseHeaders } = await apiRequest("GET", `/api/html/${namespace}/${id}`, undefined, headers, "text");
406
- if (!ok) {
407
- let errorData = data;
408
- try {
409
- errorData = JSON.parse(data);
410
- }
411
- catch { }
412
- return {
413
- content: [
414
- {
415
- type: "text",
416
- text: `Failed to fetch page (${status}): ${JSON.stringify(errorData)}`,
417
- },
418
- ],
419
- isError: true,
420
- };
421
- }
422
- const routingCohort = responseHeaders.get("X-Routing-Cohort");
423
- const deliveryMode = responseHeaders.get("X-Delivery-Mode");
424
- const hosting = { ...(routingCohort !== null ? { routingCohort } : {}), ...(deliveryMode !== null ? { deliveryMode } : {}) };
425
- const hasNewMarkers = (routingCohort !== null && routingCohort !== "old") || deliveryMode !== null;
426
- const lines = [
427
- `Page: ${namespace}/${id}`,
428
- `URL: ${responseHeaders.get("X-Canonical-Link") ?? (hasNewMarkers ? "N/A" : `${BASE_URL}/${namespace}/${id}`)}`,
429
- `Content-Type: ${responseHeaders.get("Content-Type") || "N/A"}`,
430
- ...hostingResponseLines(hosting),
431
- ``,
432
- `--- Content ---`,
433
- data,
434
- ];
435
- return {
436
- content: [{ type: "text", text: lines.join("\n") }],
437
- };
438
- });
439
- server.tool("get_stats", "Get BrewPage platform-wide usage statistics including page count, file count, and storage usage. Counts use disjoint split: alive_public + alive_private + deleted == total. Optional `tz` shifts the 'today' boundary to the given IANA zone (default UTC).", {
440
- tz: z
441
- .string()
442
- .optional()
443
- .describe("Optional IANA timezone id for the 'today' boundary, e.g. 'Europe/Lisbon'. Defaults to UTC. Invalid value falls back to UTC silently."),
444
- }, async ({ tz }) => {
445
- const path = tz ? `/api/stats?tz=${encodeURIComponent(tz)}` : "/api/stats";
446
- const { ok, data } = await apiRequest("GET", path);
447
- if (!ok) {
448
- return {
449
- content: [
450
- {
451
- type: "text",
452
- text: `Failed to fetch stats: ${JSON.stringify(data)}`,
453
- },
454
- ],
455
- isError: true,
456
- };
457
- }
458
- return {
459
- content: [
460
- {
461
- type: "text",
462
- text: `BrewPage Platform Statistics:\n\n${JSON.stringify(data, null, 2)}`,
463
- },
464
- ],
465
- };
466
- });
467
- 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 +
468
- " " + MANAGE_URL_TEXT + " " + SECRETS, {
469
- deliveryMode: DELIVERY_MODE_SCHEMA,
470
- entryContent: z
471
- .string()
472
- .optional()
473
- .describe("HTML content for a single-page site (entry file). Mutually exclusive with `files`."),
474
- files: z
475
- .array(z.object({ path: z.string(), content: z.string() }))
476
- .optional()
477
- .describe("Multi-file site: array of `{path, content}`. Mutually exclusive with `entryContent`."),
478
- entry: z
479
- .string()
480
- .optional()
481
- .describe("Entry file path, default index.html"),
482
- namespace: z
483
- .string()
484
- .optional()
485
- .describe(SITE_NAMESPACE_DESCRIPTION),
486
- password: z
487
- .string()
488
- .optional()
489
- .describe("Optional password to protect the site"),
490
- ttlDays: TTL_SCHEMA,
491
- ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
492
- }, async ({ entryContent, files, entry, namespace, password, ttlDays, ownerToken, deliveryMode }) => {
493
- const hasEntry = typeof entryContent === "string";
494
- const hasFiles = Array.isArray(files) && files.length > 0;
495
- if (hasEntry === hasFiles) {
496
- return {
497
- content: [
498
- {
499
- type: "text",
500
- text: "Provide exactly one of `entryContent` (single-page) or `files` (multi-page).",
501
- },
502
- ],
503
- isError: true,
504
- };
505
- }
506
- const formData = new FormData();
507
- if (hasFiles) {
508
- for (const f of files) {
509
- formData.append("files", new Blob([f.content], { type: "text/html" }), f.path);
510
- formData.append("paths", f.path);
511
- }
512
- }
513
- else {
514
- const entryBlob = new Blob([entryContent], { type: "text/html" });
515
- const entryName = entry || "index.html";
516
- formData.append("files", entryBlob, entryName);
517
- formData.append("paths", entryName);
518
- }
519
- const headers = {};
520
- if (password)
521
- headers["X-Password"] = password;
522
- const owner = ownerTokenFor(ownerToken).token;
523
- if (owner)
524
- headers["X-Owner-Token"] = owner;
525
- if (deliveryMode)
526
- headers["X-Delivery-Mode"] = deliveryMode;
527
- const effectiveNamespace = resolveNamespace(namespace);
528
- const qsParams = [];
529
- if (entry)
530
- qsParams.push(`entry=${encodeURIComponent(entry)}`);
531
- qsParams.push(`ns=${encodeURIComponent(effectiveNamespace)}`);
532
- if (ttlDays)
533
- qsParams.push(`ttl=${encodeURIComponent(String(ttlDays))}`);
534
- const qs = qsParams.length ? `?${qsParams.join("&")}` : "";
535
- const url = `${BASE_URL}/api/sites${qs}`;
536
- const res = await bpFetch(url, { method: "POST", headers, body: formData });
537
- const data = await res.json().catch(() => null);
538
- if (!res.ok) {
539
- return {
540
- content: [
541
- {
542
- type: "text",
543
- text: `Failed to publish site: ${JSON.stringify(data)}`,
544
- },
545
- ],
546
- isError: true,
547
- };
548
- }
549
- const site = data;
550
- const lines = [
551
- `Site published successfully!`,
552
- ``,
553
- `URL: ${site.link || "N/A"}`,
554
- `Namespace: ${site.namespace || "N/A"}`,
555
- `ID: ${site.id || "N/A"}`,
556
- `Entry file: ${site.entryFile || "index.html"}`,
557
- `Files: ${site.fileCount || 1}`,
558
- ];
559
- if (site.totalSizeBytes !== undefined)
560
- lines.push(`Size: ${site.totalSizeBytes} bytes`);
561
- if (site.ownerLink)
562
- lines.push(`Owner link: ${site.ownerLink}`);
563
- if (Array.isArray(site.tags) && site.tags.length)
564
- lines.push(`Tags: ${site.tags.join(", ")}`);
565
- if (site.expiresAt)
566
- lines.push(`Expires: ${site.expiresAt}`);
567
- lines.push(...hostingResponseLines(site));
568
- lines.push(...ownerTokenLines(site, "view info or delete this site", owner));
569
- lines.push(...ownerLinkLines(site));
570
- const ns = (site.namespace || effectiveNamespace) || "";
571
- if (ns !== PUBLIC_NAMESPACE) {
572
- lines.push(``, site.access === "protected" ? PROTECTED_UNLISTED_NOTICE : UNLISTED_NOTICE + LISTABLE_HINT);
573
- }
574
- return {
575
- content: [{ type: "text", text: lines.join("\n") }],
576
- };
577
- });
578
- 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, {
579
- namespace: z.string().describe("Site namespace"),
580
- id: z.string().describe("Site ID"),
581
- ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
582
- entryContent: z
583
- .string()
584
- .optional()
585
- .describe("HTML content for a single-page site (entry file). Mutually exclusive with `files`."),
586
- files: z
587
- .array(z.object({ path: z.string(), content: z.string() }))
588
- .optional()
589
- .describe("Multi-file site: array of `{path, content}`. Mutually exclusive with `entryContent`."),
590
- entry: z
591
- .string()
592
- .optional()
593
- .describe("Entry file path, default index.html"),
594
- ttlDays: TTL_SCHEMA.describe("Ignored: republishing keeps the existing expiry; a supplied value comes back as a ttl-ignored notice."),
595
- tags: z
596
- .array(z.string())
597
- .optional()
598
- .describe("Optional tags (replaces the site's existing tags)"),
599
- }, async ({ namespace, id, ownerToken, entryContent, files, entry, ttlDays, tags }) => {
600
- const hasEntry = typeof entryContent === "string";
601
- const hasFiles = Array.isArray(files) && files.length > 0;
602
- if (hasEntry === hasFiles) {
603
- return {
604
- content: [
605
- {
606
- type: "text",
607
- text: "Provide exactly one of `entryContent` (single-page) or `files` (multi-page).",
608
- },
609
- ],
610
- isError: true,
611
- };
612
- }
613
- const formData = new FormData();
614
- if (hasFiles) {
615
- for (const f of files) {
616
- formData.append("files", new Blob([f.content], { type: "text/html" }), f.path);
617
- formData.append("paths", f.path);
618
- }
619
- }
620
- else {
621
- const entryBlob = new Blob([entryContent], { type: "text/html" });
622
- const entryName = entry || "index.html";
623
- formData.append("files", entryBlob, entryName);
624
- formData.append("paths", entryName);
625
- }
626
- const auth = ownerTokenFor(ownerToken);
627
- if (!auth.token)
628
- return textResult(NO_OWNER_TOKEN, true);
629
- const headers = { "X-Owner-Token": auth.token };
630
- const qsParams = [];
631
- if (entry)
632
- qsParams.push(`entry=${encodeURIComponent(entry)}`);
633
- if (ttlDays)
634
- qsParams.push(`ttl=${encodeURIComponent(String(ttlDays))}`);
635
- if (tags && tags.length)
636
- qsParams.push(`tags=${encodeURIComponent(tags.join(","))}`);
637
- const qs = qsParams.length ? `?${qsParams.join("&")}` : "";
638
- const url = `${BASE_URL}/api/sites/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}${qs}`;
639
- const res = await bpFetch(url, { method: "PUT", headers, body: formData });
640
- const data = await res.json().catch(() => null);
641
- if (!res.ok)
642
- return ownerFailure("republish site", res.status, data, auth.fromEnv);
643
- const site = data;
644
- const lines = [
645
- `Site republished successfully (full replace)!`,
646
- ``,
647
- `URL: ${site.link || "N/A"}`,
648
- `Namespace: ${site.namespace || namespace}`,
649
- `ID: ${site.id || id}`,
650
- `Entry file: ${site.entryFile || "index.html"}`,
651
- `Files: ${site.fileCount || 1}`,
652
- ];
653
- if (site.totalSizeBytes !== undefined)
654
- lines.push(`Size: ${site.totalSizeBytes} bytes`);
655
- if (site.ownerLink)
656
- lines.push(`Owner link: ${site.ownerLink}`);
657
- if (Array.isArray(site.tags) && site.tags.length)
658
- lines.push(`Tags: ${site.tags.join(", ")}`);
659
- if (site.expiresAt)
660
- lines.push(`Expires: ${site.expiresAt}`);
661
- if (site.result)
662
- lines.push(`Result: ${site.result}`);
663
- lines.push(...hostingResponseLines(site));
664
- if (site.visibilityNotice)
665
- lines.push(``, String(site.visibilityNotice));
666
- return {
667
- content: [{ type: "text", text: lines.join("\n") }],
668
- };
669
- });
670
- server.tool("update_html", "Update an existing BrewPage HTML/Markdown page. Requires owner token. Returns the updated page metadata. " +
671
- "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, {
672
- namespace: z.string().describe("Page namespace"),
673
- id: z.string().describe("Page ID"),
674
- content: z.string().describe("New HTML or Markdown content"),
675
- ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
676
- format: z
677
- .string()
678
- .optional()
679
- .describe("Content format. Accepts (case-insensitive on html/markdown): 'html', 'markdown'/'md', or a code language — yaml, json, xml, csv, tsv, log, toml, ini, sql, sh, bat, env, javascript, typescript, tsx, jsx, css, properties, docker, txt. Omit to preserve the stored format."),
680
- }, async ({ namespace, id, content, ownerToken, format }) => {
681
- const body = { content };
682
- if (format)
683
- body.format = format;
684
- const auth = ownerTokenFor(ownerToken);
685
- if (!auth.token)
686
- return textResult(NO_OWNER_TOKEN, true);
687
- const path = `/api/html/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}`;
688
- const { ok, status, data } = await apiRequest("PUT", path, body, {
689
- "X-Owner-Token": auth.token,
690
- });
691
- if (!ok)
692
- return ownerFailure("update HTML", status, data, auth.fromEnv);
693
- return {
694
- content: [
695
- {
696
- type: "text",
697
- text: formatUpdateResponse(data),
698
- },
699
- ],
700
- };
701
- });
702
- 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. " +
703
- MANAGE_URL_TEXT + " " + SECRETS, {
704
- deliveryMode: DELIVERY_MODE_SCHEMA,
705
- json: z
706
- .union([z.string(), z.record(z.unknown()), z.array(z.unknown())])
707
- .describe("JSON document to publish (object, array, or JSON-encoded string)"),
708
- namespace: z
709
- .string()
710
- .optional()
711
- .describe(DATA_NAMESPACE_DESCRIPTION),
712
- password: z
713
- .string()
714
- .optional()
715
- .describe("Optional password to protect the document"),
716
- ttlDays: TTL_SCHEMA,
717
- tags: z
718
- .array(z.string())
719
- .optional()
720
- .describe("Optional tags for the document"),
721
- ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
722
- }, async ({ json, namespace, password, ttlDays, tags, ownerToken, deliveryMode }) => {
723
- const jsonString = typeof json === "string" ? json : JSON.stringify(json);
724
- const effectiveNamespace = resolveNamespace(namespace);
725
- const qsParams = [];
726
- qsParams.push(`ns=${encodeURIComponent(effectiveNamespace)}`);
727
- if (ttlDays !== undefined)
728
- qsParams.push(`ttl=${encodeURIComponent(String(ttlDays))}`);
729
- if (tags && tags.length) {
730
- for (const t of tags)
731
- qsParams.push(`tags=${encodeURIComponent(t)}`);
732
- }
733
- const qs = qsParams.length ? `?${qsParams.join("&")}` : "";
734
- const headers = { "Content-Type": "application/json" };
735
- if (password)
736
- headers["X-Password"] = password;
737
- const owner = ownerTokenFor(ownerToken).token;
738
- if (owner)
739
- headers["X-Owner-Token"] = owner;
740
- if (deliveryMode)
741
- headers["X-Delivery-Mode"] = deliveryMode;
742
- const res = await bpFetch(`${BASE_URL}/api/json${qs}`, {
743
- method: "POST",
744
- headers,
745
- body: jsonString,
746
- });
747
- const data = await res.json().catch(() => null);
748
- if (!res.ok) {
749
- return {
750
- content: [
751
- {
752
- type: "text",
753
- text: `Failed to publish JSON (${res.status}): ${JSON.stringify(data)}`,
754
- },
755
- ],
756
- isError: true,
757
- };
758
- }
759
- return {
760
- content: [
761
- {
762
- type: "text",
763
- text: formatPublishResponse(data, effectiveNamespace, owner, false),
764
- },
765
- ],
766
- };
767
- });
768
- server.tool("get_json", "Fetch a published BrewPage JSON document by namespace and ID.", {
769
- namespace: z.string().describe("Document namespace"),
770
- id: z.string().describe("Document ID"),
771
- password: z
772
- .string()
773
- .optional()
774
- .describe("Password if the document is protected"),
775
- }, async ({ namespace, id, password }) => {
776
- const headers = {};
777
- if (password)
778
- headers["X-Password"] = password;
779
- const path = `/api/json/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}`;
780
- const { ok, status, data } = await apiRequest("GET", path, undefined, headers);
781
- if (!ok) {
782
- return {
783
- content: [
784
- {
785
- type: "text",
786
- text: `Failed to fetch JSON (${status}): ${JSON.stringify(data)}`,
787
- },
788
- ],
789
- isError: true,
790
- };
791
- }
792
- return {
793
- content: [
794
- {
795
- type: "text",
796
- text: JSON.stringify(data, null, 2),
797
- },
798
- ],
799
- };
800
- });
801
- 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, {
802
- namespace: z.string().describe("Document namespace"),
803
- id: z.string().describe("Document ID"),
804
- json: z
805
- .union([z.string(), z.record(z.unknown()), z.array(z.unknown())])
806
- .describe("New JSON document (object, array, or JSON-encoded string)"),
807
- ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
808
- }, async ({ namespace, id, json, ownerToken }) => {
809
- const jsonString = typeof json === "string" ? json : JSON.stringify(json);
810
- const auth = ownerTokenFor(ownerToken);
811
- if (!auth.token)
812
- return textResult(NO_OWNER_TOKEN, true);
813
- const url = `${BASE_URL}/api/json/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}`;
814
- const res = await bpFetch(url, {
815
- method: "PUT",
816
- headers: {
817
- "Content-Type": "application/json",
818
- "X-Owner-Token": auth.token,
819
- },
820
- body: jsonString,
821
- });
822
- const data = await res.json().catch(() => null);
823
- if (!res.ok)
824
- return ownerFailure("update JSON", res.status, data, auth.fromEnv);
825
- return {
826
- content: [
827
- {
828
- type: "text",
829
- text: formatUpdateResponse(data),
830
- },
831
- ],
832
- };
833
- });
834
- 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. " +
835
- MANAGE_URL_TEXT + " " + SECRETS, {
836
- deliveryMode: DELIVERY_MODE_SCHEMA,
837
- key: z.string().describe("KV key (first part of the (namespace, id, key) addressing tuple)"),
838
- value: z.string().describe("KV value"),
839
- namespace: z
840
- .string()
841
- .optional()
842
- .describe(DATA_NAMESPACE_DESCRIPTION),
843
- password: z
844
- .string()
845
- .optional()
846
- .describe("Optional password to protect the KV store"),
847
- ttlDays: TTL_SCHEMA,
848
- tags: z
849
- .array(z.string())
850
- .optional()
851
- .describe("Optional tags for the KV entry"),
852
- ownerToken: z.string().optional().describe(CREATE_OWNER_TOKEN_DESCRIPTION),
853
- }, async ({ key, value, namespace, password, ttlDays, tags, ownerToken, deliveryMode }) => {
854
- const body = { key, value };
855
- const effectiveNamespace = resolveNamespace(namespace);
856
- const qsParams = [];
857
- qsParams.push(`ns=${encodeURIComponent(effectiveNamespace)}`);
858
- if (ttlDays !== undefined)
859
- qsParams.push(`ttl=${encodeURIComponent(String(ttlDays))}`);
860
- if (tags && tags.length) {
861
- for (const t of tags)
862
- qsParams.push(`tags=${encodeURIComponent(t)}`);
863
- }
864
- const qs = qsParams.length ? `?${qsParams.join("&")}` : "";
865
- const headers = {};
866
- if (password)
867
- headers["X-Password"] = password;
868
- const owner = ownerTokenFor(ownerToken).token;
869
- if (owner)
870
- headers["X-Owner-Token"] = owner;
871
- if (deliveryMode)
872
- headers["X-Delivery-Mode"] = deliveryMode;
873
- const { ok, status, data } = await apiRequest("POST", `/api/kv${qs}`, body, headers);
874
- if (!ok) {
875
- return {
876
- content: [
877
- {
878
- type: "text",
879
- text: `Failed to publish KV (${status}): ${JSON.stringify(data)}`,
880
- },
881
- ],
882
- isError: true,
883
- };
884
- }
885
- return {
886
- content: [
887
- {
888
- type: "text",
889
- text: formatPublishResponse(data, effectiveNamespace, owner, false),
890
- },
891
- ],
892
- };
893
- });
894
- 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, {
895
- namespace: z.string().describe("KV store namespace"),
896
- id: z.string().describe("KV store ID"),
897
- key: z.string().describe("Key to set"),
898
- value: z.string().describe("Value to assign to the key"),
899
- ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
900
- }, async ({ namespace, id, key, value, ownerToken }) => {
901
- const body = { value };
902
- const auth = ownerTokenFor(ownerToken);
903
- if (!auth.token)
904
- return textResult(NO_OWNER_TOKEN, true);
905
- const path = `/api/kv/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}/${encodeURIComponent(key)}`;
906
- const { ok, status, data } = await apiRequest("PUT", path, body, {
907
- "X-Owner-Token": auth.token,
908
- });
909
- if (!ok)
910
- return ownerFailure("set KV", status, data, auth.fromEnv);
911
- return {
912
- content: [
913
- {
914
- type: "text",
915
- text: formatUpdateResponse(data),
916
- },
917
- ],
918
- };
919
- });
920
- server.tool("get_kv", "Fetch a value from a BrewPage KV store by namespace, ID, and key.", {
921
- namespace: z.string().describe("KV store namespace"),
922
- id: z.string().describe("KV store ID"),
923
- key: z.string().describe("Key to fetch"),
924
- password: z
925
- .string()
926
- .optional()
927
- .describe("Password if the KV store is protected"),
928
- }, async ({ namespace, id, key, password }) => {
929
- const headers = {};
930
- if (password)
931
- headers["X-Password"] = password;
932
- const path = `/api/kv/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}/${encodeURIComponent(key)}`;
933
- const { ok, status, data } = await apiRequest("GET", path, undefined, headers);
934
- if (!ok) {
935
- return {
936
- content: [
937
- {
938
- type: "text",
939
- text: `Failed to fetch KV (${status}): ${JSON.stringify(data)}`,
940
- },
941
- ],
942
- isError: true,
943
- };
944
- }
945
- return {
946
- content: [
947
- {
948
- type: "text",
949
- text: JSON.stringify(data, null, 2),
950
- },
951
- ],
952
- };
953
- });
954
- server.tool("get_limits", "Get the current BrewPage upload limits (GET /api/limits): request size, site file count, per-file and total size, " +
955
- "ZIP expansion, path and name lengths, TTL default/maximum, allowed file extensions and skipped system files. Sizes are bytes. " +
956
- `With ${OWNER_TOKEN_ENV} set: also reach (token|profile) and ownerMaxTtlDays. Call it before publishing a large or multi-file site.`, {}, async () => {
957
- const headers = ENV_OWNER_TOKEN ? { "X-Owner-Token": ENV_OWNER_TOKEN } : {};
958
- const { ok, status, data } = await apiRequest("GET", "/api/limits", undefined, headers);
959
- if (!ok) {
960
- return {
961
- content: [
962
- {
963
- type: "text",
964
- text: `Failed to fetch limits (${status}): ${JSON.stringify(data)}`,
965
- },
966
- ],
967
- isError: true,
968
- };
969
- }
970
- return {
971
- content: [
972
- {
973
- type: "text",
974
- text: `BrewPage upload limits (bytes):\n\n${JSON.stringify(data, null, 2)}`,
975
- },
976
- ],
977
- };
978
- });
979
- server.tool("search_gallery", "Search the BrewPage public gallery. Optional filters: text query, pagination, sort, and 'mine' (owner-scoped, requires owner token).", {
980
- q: z.string().optional().describe("Free-text search query"),
981
- page: z
982
- .number()
983
- .int()
984
- .min(1)
985
- .default(1)
986
- .describe("Page number (1-based; default 1)"),
987
- size: z
988
- .number()
989
- .int()
990
- .min(1)
991
- .max(100)
992
- .optional()
993
- .describe("Page size (1-100; backend caps at 100)"),
994
- sort: z
995
- .enum(["date", "views"])
996
- .optional()
997
- .describe("Sort order: 'date' or 'views'"),
998
- mine: z
999
- .boolean()
1000
- .optional()
1001
- .describe("If true, restricts results to entries owned by the supplied owner token"),
1002
- ownerToken: z
1003
- .string()
1004
- .optional()
1005
- .describe(`Owner token; only sent as X-Owner-Token when 'mine' is true. Default: ${OWNER_TOKEN_ENV}.`),
1006
- }, async ({ q, page, size, sort, mine, ownerToken }) => {
1007
- const auth = ownerTokenFor(ownerToken);
1008
- if (mine === true && !auth.token)
1009
- return textResult(NO_OWNER_TOKEN, true);
1010
- const qsParams = [];
1011
- if (q !== undefined)
1012
- qsParams.push(`q=${encodeURIComponent(q)}`);
1013
- if (page !== undefined)
1014
- qsParams.push(`page=${encodeURIComponent(String(page))}`);
1015
- if (size !== undefined)
1016
- qsParams.push(`size=${encodeURIComponent(String(size))}`);
1017
- if (sort)
1018
- qsParams.push(`sort=${encodeURIComponent(sort)}`);
1019
- if (mine === true)
1020
- qsParams.push("mine=true");
1021
- const qs = qsParams.length ? `?${qsParams.join("&")}` : "";
1022
- const headers = {};
1023
- if (mine === true && auth.token)
1024
- headers["X-Owner-Token"] = auth.token;
1025
- const { ok, status, data } = await apiRequest("GET", `/api/gallery${qs}`, undefined, headers);
1026
- if (!ok) {
1027
- return {
1028
- content: [
1029
- {
1030
- type: "text",
1031
- text: `Failed to search gallery (${status}): ${JSON.stringify(data)}`,
1032
- },
1033
- ],
1034
- isError: true,
1035
- };
1036
- }
1037
- return {
1038
- content: [
1039
- {
1040
- type: "text",
1041
- text: JSON.stringify(data, null, 2),
1042
- },
1043
- ],
1044
- };
1045
- });
1046
- server.tool("set_visibility", "Hide or show a public publication in the gallery and sitemap. Link unchanged.", {
1047
- type: z.enum(["html", "site", "file", "json", "kv"]).describe("Publication type; only public html, site and file can be listed"),
1048
- namespace: z.string().describe("Publication namespace"),
1049
- id: z.string().describe("Publication ID"),
1050
- listed: z.boolean().describe("false = hide, true = show"),
1051
- ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
1052
- }, async ({ type, namespace, id, listed, ownerToken }) => {
1053
- const auth = ownerTokenFor(ownerToken);
1054
- if (!auth.token)
1055
- return textResult(NO_OWNER_TOKEN, true);
1056
- const path = `/api/${TYPE_SEGMENTS[type]}/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}/visibility`;
1057
- const { ok, status, data } = await apiRequest("POST", path, { listed }, { "X-Owner-Token": auth.token });
1058
- if (!ok)
1059
- return ownerFailure("set visibility", status, data, auth.fromEnv);
1060
- const result = data;
1061
- return textResult([`LISTED: ${result.listed} · link unchanged`, ...noticeLines(result)].join("\n"));
1062
- });
1063
- server.tool("bind_token", "Add an owner token to your BrewPage profile (as publish). Needs an admin token of that profile.", {
1064
- token: z.string().describe("Owner token to add"),
1065
- ownerToken: z.string().optional().describe(`Admin token of the profile. Default: ${OWNER_TOKEN_ENV}.`),
1066
- }, async ({ token, ownerToken }) => {
1067
- const auth = ownerTokenFor(ownerToken);
1068
- if (!auth.token)
1069
- return textResult(NO_OWNER_TOKEN, true);
1070
- const { ok, status, data } = await apiRequest("POST", "/api/owner-token/bind", { token }, { "X-Owner-Token": auth.token });
1071
- if (!ok)
1072
- return textResult(`Failed to bind token (${status}): ${JSON.stringify(data)}`, true);
1073
- const result = data;
1074
- return textResult(`BOUND ${maskToken(token)} · ${result.active}/${result.max} active`);
1075
- });
1076
- 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.", {
1077
- label: z.string().optional().describe("Optional label shown in the profile"),
1078
- scope: z.enum(["publish", "admin"]).optional().describe("publish (default) or admin (can manage tokens)"),
1079
- expiresIn: z.enum(["30d", "90d", "1y", "never"]).optional().describe("Default: admin 90d, publish never"),
1080
- ownerToken: z.string().optional().describe(`Admin token of the profile. Default: ${OWNER_TOKEN_ENV}.`),
1081
- }, async ({ label, scope, expiresIn, ownerToken }) => {
1082
- const auth = ownerTokenFor(ownerToken);
1083
- if (!auth.token)
1084
- return textResult(NO_OWNER_TOKEN, true);
1085
- const { ok, status, data } = await apiRequest("POST", "/api/owner-token/mint", { label, scope, expiresIn }, { "X-Owner-Token": auth.token });
1086
- if (!ok)
1087
- return textResult(`Failed to mint token (${status}): ${JSON.stringify(data)}`, true);
1088
- const result = data;
1089
- return textResult([
1090
- `MINTED · ${result.scope} · expires ${result.expiresAt ?? "never"}`,
1091
- ``,
1092
- `===================================`,
1093
- `NEW TOKEN (shown once): ${result.token}`,
1094
- `===================================`,
1095
- ``,
1096
- `Save it now; it cannot be shown again. Never publish it.`,
1097
- ].join("\n"));
1098
- });
1099
15
  async function main() {
16
+ const server = createServer({ baseUrl: process.env.BREWPAGE_URL || DEFAULT_BASE_URL, ownerToken: ENV_OWNER_TOKEN });
1100
17
  const transport = new StdioServerTransport();
1101
18
  await server.connect(transport);
1102
19
  }