webcake-landing-mcp 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -184,6 +184,48 @@ The MCP config is the same as the local one, but `command`/`args` point at `npx`
184
184
  > npx caches the package after the first run, so subsequent launches are fast. Use a pinned version
185
185
  > (`webcake-landing-mcp@1.0.0`) if you need a reproducible build.
186
186
 
187
+ ## Run as a remote connector (Streamable HTTP)
188
+
189
+ The server also speaks the **remote MCP** (Streamable HTTP) transport, so it can be added through
190
+ Claude's **"Add custom connector"** dialog via a URL — not just as a local stdio server.
191
+
192
+ Start it in HTTP mode (default port `8787`, or set `PORT` / `--port`):
193
+
194
+ ```bash
195
+ npx -y webcake-landing-mcp serve --port 8787
196
+ # → MCP endpoint at http://localhost:8787/mcp (GET / or /health returns a status JSON)
197
+ ```
198
+
199
+ Expose it over **HTTPS** at a public URL (a reverse proxy, a tunnel like `ngrok http 8787`, or any
200
+ host), then in Claude → **Add custom connector**:
201
+
202
+ - **Name**: `webcake-landing`
203
+ - **Remote MCP server URL**: `https://<your-host>/mcp`
204
+
205
+ ### Auth — per-request, multi-user (no shared token)
206
+
207
+ In stdio mode the JWT comes from env. In HTTP mode each request carries the caller's **own** credentials
208
+ via headers, so a hosted server is multi-user and never bakes in a shared secret:
209
+
210
+ | Header | Maps to | Notes |
211
+ |--------|---------|-------|
212
+ | `x-webcake-jwt` (or `Authorization: Bearer <jwt>`) | `WEBCAKE_JWT` | the account token — sent per request |
213
+ | `x-webcake-org-id` | `WEBCAKE_ORG_ID` | default org |
214
+ | `x-webcake-api-base` | `WEBCAKE_API_BASE` | usually set once via env on the host instead |
215
+ | `x-webcake-host` | `WEBCAKE_HOST` | Phoenix host-routing header |
216
+ | `x-webcake-app-base` | `WEBCAKE_APP_BASE` | editor/preview URL base |
217
+
218
+ Any header that is absent falls back to the corresponding env var — so you can also run it **single-user**
219
+ by setting `WEBCAKE_API_BASE` + `WEBCAKE_JWT` in the host's env and keeping the URL private.
220
+
221
+ > ⚠️ The reference + generation tools (`get_generation_guide`, `list_elements`, `validate_page`, …) need
222
+ > no secret; only the persistence tools (`create_page`, `update_page`, …) use the JWT. If a request has no
223
+ > JWT, those tools return `missing_env` instead of touching the network.
224
+ >
225
+ > Note: the basic claude.ai connector dialog may not let you set custom headers (it offers OAuth, which this
226
+ > server does not implement yet). For the header-based flow, use a client/proxy that can inject headers, or
227
+ > run single-user with env vars behind a private URL.
228
+
187
229
  ## Manual Setup (local)
188
230
 
189
231
  ```bash
@@ -197,6 +239,53 @@ npm run smoke # offline self-test of factory + validator (prints "ALL GOOD"
197
239
  The reference/validation tools work with **zero config**. Env vars are only needed for the persistence
198
240
  tools (`create_page`, `update_page`, `list_pages`, `get_page`, `list_organizations`).
199
241
 
242
+ ## Connect once — grab your token automatically (`login`)
243
+
244
+ Instead of copying a JWT by hand, run:
245
+
246
+ ```bash
247
+ # Production — zero config (defaults: connect via webcake.io, API via api.webcake.io):
248
+ npx -y webcake-landing-mcp login
249
+
250
+ # Local dev — point at your local SPA (5173) + API (5800):
251
+ node dist/index.js login \
252
+ --connect-url http://localhost:5173/mcp-connect \
253
+ --api-base http://localhost:5800
254
+ ```
255
+
256
+ It opens your browser → (log into Webcake if needed) → the token is saved to
257
+ `~/.webcake-landing-mcp/auth.json`, which the server then reads automatically.
258
+
259
+ You're already logged in to Webcake in your browser, so `login` just opens a Webcake "connect"
260
+ page that reads your `jwt` cookie server-side and hands the token back to a localhost callback —
261
+ no copy-paste. The saved token is used by **both** the stdio server and a single-user `serve`
262
+ deployment (env vars still take precedence). JWTs last 90–365 days, so you rarely reconnect.
263
+
264
+ Two URLs, don't mix them up:
265
+
266
+ - **Connect page = the SPA** (`--connect-url` / `WEBCAKE_CONNECT_URL`): `https://webcake.io/mcp-connect`
267
+ in prod, `http://localhost:5173/mcp-connect` locally. Otherwise derived from `WEBCAKE_APP_BASE` +
268
+ `/mcp-connect`, defaulting to `https://webcake.io/mcp-connect`.
269
+ - **API base = the backend** (`--api-base` / `WEBCAKE_API_BASE`): `https://api.webcake.io` in prod,
270
+ `http://localhost:5800` locally. Defaults to `https://api.webcake.io`.
271
+
272
+ Other flags: `--org-id`, `--port`, `--no-open`. Saved-file dir: `WEBCAKE_CONFIG_DIR` (default
273
+ `~/.webcake-landing-mcp`).
274
+
275
+ **Backend endpoint to add** (in your Webcake backend — it owns the session cookie):
276
+
277
+ ```
278
+ GET /mcp-connect?redirect_uri=<loopback>&state=<s>
279
+ → read the `jwt` cookie (the logged-in user's token)
280
+ → 302 to <redirect_uri>?token=<jwt>&state=<s>
281
+ (if there's no cookie: 302 to the login page first, then back here)
282
+ ```
283
+
284
+ For safety, only honor `redirect_uri` values on `http://127.0.0.1:*` / `http://localhost:*`.
285
+
286
+ > Multi-user remote (the claude.ai connector dialog) can't do this browser loopback — there each
287
+ > user sends their own token via the `x-webcake-jwt` header (see the remote-connector section above).
288
+
200
289
  ## Environment Variables
201
290
 
202
291
  | Variable | Required | Description |
@@ -206,6 +295,8 @@ tools (`create_page`, `update_page`, `list_pages`, `get_page`, `list_organizatio
206
295
  | `WEBCAKE_ORG_ID` | No | Default organization id for `create_page` (overridden by its `organization_id` arg). Omit → personal page. |
207
296
  | `WEBCAKE_HOST` | No | Optional `Host` header (Phoenix routes by host, e.g. `builder.localhost`). |
208
297
  | `WEBCAKE_APP_BASE` | No | Optional base used to build editor/preview URLs in the result. |
298
+ | `WEBCAKE_CONNECT_URL` | No | The SPA "connect" page for `login` (default `https://webcake.io/mcp-connect`; else `WEBCAKE_APP_BASE` + `/mcp-connect`). |
299
+ | `WEBCAKE_CONFIG_DIR` | No | Dir for the saved `auth.json` written by `login` (default `~/.webcake-landing-mcp`). |
209
300
 
210
301
  > \* `WEBCAKE_API_BASE` and `WEBCAKE_JWT` are only needed for the persistence tools. The reference and
211
302
  > validation tools (`get_generation_guide`, `list_elements`, `get_element`, `validate_page`, …) work without them.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `webcake-landing-mcp login` — grab the user's Webcake JWT automatically via the
3
+ * browser, no copy-paste.
4
+ *
5
+ * Flow (works for local stdio AND a single-user remote deploy):
6
+ * 1. open a loopback server on 127.0.0.1:<port>,
7
+ * 2. open the browser to the Webcake "connect" URL with redirect_uri=<loopback>,
8
+ * 3. the user is already logged in to Webcake, so Webcake reads their `jwt`
9
+ * cookie server-side and 302s back to the loopback with ?token=<jwt>,
10
+ * 4. we save it to the credentials file (persistence/config.ts#saveSavedConfig),
11
+ * which the stdio/http server then reads automatically.
12
+ *
13
+ * Backend contract (added to landing_page_backend — owned by the user):
14
+ * GET {WEBCAKE_CONNECT_URL}?redirect_uri=<loopback>&state=<s>
15
+ * → read cookie `jwt` → 302 to <redirect_uri>?token=<jwt>&state=<s>
16
+ * (or 302 to the login page first, then back). Restrict redirect_uri to
17
+ * http://127.0.0.1:* / http://localhost:* for safety.
18
+ */
19
+ import { createServer } from "node:http";
20
+ import { randomBytes } from "node:crypto";
21
+ import { spawn } from "node:child_process";
22
+ import { saveSavedConfig } from "../persistence/config.js";
23
+ // Production defaults — the connect page lives on the SPA (webcake.io), the API
24
+ // lives on api.webcake.io. For local dev override with --connect-url / --api-base
25
+ // (e.g. http://localhost:5173/mcp-connect and http://localhost:5800) or the
26
+ // WEBCAKE_APP_BASE / WEBCAKE_API_BASE env vars.
27
+ const DEFAULT_CONNECT_URL = "https://webcake.io/mcp-connect";
28
+ const DEFAULT_API_BASE = "https://api.webcake.io";
29
+ function parseArgs(argv) {
30
+ const get = (name) => {
31
+ const i = argv.indexOf(name);
32
+ return i !== -1 ? argv[i + 1] : undefined;
33
+ };
34
+ return {
35
+ connectUrl: get("--connect-url"),
36
+ apiBase: get("--api-base"),
37
+ orgId: get("--org-id"),
38
+ port: get("--port") ? Number(get("--port")) : undefined,
39
+ open: !argv.includes("--no-open"),
40
+ };
41
+ }
42
+ function openBrowser(url) {
43
+ const platform = process.platform;
44
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
45
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
46
+ try {
47
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
48
+ }
49
+ catch {
50
+ /* ignore — the URL is also printed */
51
+ }
52
+ }
53
+ const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>Connected</title>
54
+ <body style="font-family:system-ui;text-align:center;padding:48px">
55
+ <h2>✓ Connected to Webcake</h2><p>You can close this tab and return to your terminal.</p></body>`;
56
+ function resolveConnectUrl(opts) {
57
+ if (opts.connectUrl)
58
+ return opts.connectUrl;
59
+ if (process.env.WEBCAKE_CONNECT_URL)
60
+ return process.env.WEBCAKE_CONNECT_URL;
61
+ // The connect page is on the SPA (WEBCAKE_APP_BASE), NOT the API base.
62
+ const appBase = process.env.WEBCAKE_APP_BASE;
63
+ if (appBase)
64
+ return `${appBase.replace(/\/+$/, "")}/mcp-connect`;
65
+ return DEFAULT_CONNECT_URL;
66
+ }
67
+ export async function runLogin(argv) {
68
+ const opts = parseArgs(argv);
69
+ const connectUrl = resolveConnectUrl(opts);
70
+ const apiBase = opts.apiBase || process.env.WEBCAKE_API_BASE || DEFAULT_API_BASE;
71
+ const state = randomBytes(16).toString("hex");
72
+ await new Promise((resolve, reject) => {
73
+ const server = createServer((req, res) => {
74
+ const url = new URL(req.url || "/", "http://127.0.0.1");
75
+ if (url.pathname !== "/callback") {
76
+ res.writeHead(404).end("Not found");
77
+ return;
78
+ }
79
+ const token = url.searchParams.get("token");
80
+ if (!token || url.searchParams.get("state") !== state) {
81
+ res.writeHead(400, { "content-type": "text/html" }).end("<p>Invalid or expired login — re-run the command.</p>");
82
+ return;
83
+ }
84
+ const path = saveSavedConfig({
85
+ jwt: token,
86
+ ...(apiBase ? { base: apiBase.replace(/\/+$/, "") } : {}),
87
+ ...(opts.orgId ? { orgId: opts.orgId } : {}),
88
+ savedAt: new Date().toISOString(),
89
+ });
90
+ res.writeHead(200, { "content-type": "text/html" }).end(SUCCESS_HTML);
91
+ console.error(`\n✓ Connected. Token saved to ${path}`);
92
+ if (!apiBase) {
93
+ console.error(" tip: also set WEBCAKE_API_BASE (or pass --api-base) so the server knows the backend URL.");
94
+ }
95
+ server.close();
96
+ resolve();
97
+ });
98
+ server.on("error", reject);
99
+ server.listen(opts.port ?? 0, "127.0.0.1", () => {
100
+ const addr = server.address();
101
+ const port = typeof addr === "object" && addr ? addr.port : opts.port;
102
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
103
+ const sep = connectUrl.includes("?") ? "&" : "?";
104
+ const full = `${connectUrl}${sep}redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`;
105
+ console.error("Opening your browser to connect to Webcake (log in there if prompted):");
106
+ console.error(" " + full + "\n");
107
+ if (opts.open)
108
+ openBrowser(full);
109
+ });
110
+ setTimeout(() => {
111
+ server.close();
112
+ reject(new Error("login timed out after 180s."));
113
+ }, 180_000).unref();
114
+ });
115
+ }
package/dist/http.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Remote transport: a Streamable-HTTP server so the MCP can be added as a Claude
3
+ * "custom connector" via a public URL — alongside the stdio mode in index.ts.
4
+ *
5
+ * Stateful sessions: an `initialize` POST (no session id) spins up a fresh
6
+ * McpServer + transport and returns an `mcp-session-id`; later requests reuse it
7
+ * via that header. Every request's headers carry the caller's OWN Webcake JWT
8
+ * (see persistence/config.ts#configFromHeaders), so a hosted server is multi-user
9
+ * and never bakes a shared token into env.
10
+ *
11
+ * All logging stays on stderr (console.error), same as stdio mode.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { createServer as createHttpServer } from "node:http";
15
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
16
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
17
+ import { createServer } from "./server.js";
18
+ const MCP_PATH = "/mcp";
19
+ function sendJson(res, status, body) {
20
+ res.writeHead(status, { "content-type": "application/json" });
21
+ res.end(JSON.stringify(body));
22
+ }
23
+ function rpcError(res, status, message) {
24
+ sendJson(res, status, { jsonrpc: "2.0", error: { code: -32000, message }, id: null });
25
+ }
26
+ async function readBody(req) {
27
+ const chunks = [];
28
+ for await (const c of req)
29
+ chunks.push(c);
30
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
31
+ return raw ? JSON.parse(raw) : undefined;
32
+ }
33
+ export async function startHttpServer(port) {
34
+ // mcp-session-id -> live transport (each bound to its own McpServer instance).
35
+ const transports = new Map();
36
+ const httpServer = createHttpServer(async (req, res) => {
37
+ const path = (req.url ?? "").split("?")[0];
38
+ // Lightweight health check for hosting platforms.
39
+ if (req.method === "GET" && (path === "/" || path === "/health")) {
40
+ return sendJson(res, 200, { ok: true, server: "webcake-landing", transport: "streamable-http", endpoint: MCP_PATH });
41
+ }
42
+ if (path !== MCP_PATH)
43
+ return rpcError(res, 404, `Not found. Send MCP requests to ${MCP_PATH}.`);
44
+ const sidHeader = req.headers["mcp-session-id"];
45
+ const sessionId = Array.isArray(sidHeader) ? sidHeader[0] : sidHeader;
46
+ try {
47
+ // Existing session: delegate any method (POST/GET/DELETE) to its transport.
48
+ if (sessionId && transports.has(sessionId)) {
49
+ const transport = transports.get(sessionId);
50
+ const body = req.method === "POST" ? await readBody(req) : undefined;
51
+ await transport.handleRequest(req, res, body);
52
+ return;
53
+ }
54
+ // New session: only a POST `initialize` may open one.
55
+ if (req.method === "POST") {
56
+ const body = await readBody(req);
57
+ if (!sessionId && isInitializeRequest(body)) {
58
+ const transport = new StreamableHTTPServerTransport({
59
+ sessionIdGenerator: () => randomUUID(),
60
+ onsessioninitialized: (id) => {
61
+ transports.set(id, transport);
62
+ },
63
+ });
64
+ transport.onclose = () => {
65
+ if (transport.sessionId)
66
+ transports.delete(transport.sessionId);
67
+ };
68
+ const server = createServer();
69
+ await server.connect(transport);
70
+ await transport.handleRequest(req, res, body);
71
+ return;
72
+ }
73
+ return rpcError(res, 400, "Bad Request: no valid mcp-session-id (send an initialize request first).");
74
+ }
75
+ return rpcError(res, 400, "Bad Request: missing or unknown mcp-session-id.");
76
+ }
77
+ catch (err) {
78
+ console.error("[webcake-http] request error:", err);
79
+ if (!res.headersSent)
80
+ rpcError(res, 500, "Internal server error.");
81
+ }
82
+ });
83
+ await new Promise((resolve) => httpServer.listen(port, resolve));
84
+ // stderr only.
85
+ console.error(`[webcake-elements] MCP Streamable-HTTP server ready on http://localhost:${port}${MCP_PATH}`);
86
+ }
package/dist/index.js CHANGED
@@ -2,11 +2,16 @@
2
2
  /**
3
3
  * Webcake landing MCP server (stdio) — entry point.
4
4
  *
5
- * Thin dispatcher: `webcake-landing-mcp install|uninstall|--help` runs the
6
- * bundled IDE installer; otherwise it starts the stdio MCP server. The server
7
- * itself (McpServer + tool registration) is built in ./server.ts; the knowledge,
8
- * factory, validator, and HTTP client live under ./core, ./domains, ./tools, and
9
- * ./persistence.
5
+ * Thin dispatcher:
6
+ * - `webcake-landing-mcp install|uninstall|--help` → bundled IDE installer
7
+ * - `webcake-landing-mcp login` grab the Webcake JWT via the browser and save it
8
+ * (~/.webcake-landing-mcp/auth.json); see ./auth/login.ts
9
+ * - `webcake-landing-mcp serve [--port N]` (or PORT env) → remote Streamable-HTTP
10
+ * server (for Claude "custom connector" via a public URL); see ./http.ts
11
+ * - no subcommand → stdio MCP server (the default; for desktop/CLI configs)
12
+ * The server itself (McpServer + tool registration) is built in ./server.ts; the
13
+ * knowledge, factory, validator, and HTTP client live under ./core, ./domains,
14
+ * ./tools, and ./persistence.
10
15
  *
11
16
  * stdout is the MCP channel — all logging goes to stderr (console.error) only.
12
17
  */
@@ -27,6 +32,19 @@ async function main() {
27
32
  await runInstaller(rest);
28
33
  return;
29
34
  }
35
+ if (sub === "login") {
36
+ const { runLogin } = await import("./auth/login.js");
37
+ await runLogin(process.argv.slice(3));
38
+ return;
39
+ }
40
+ if (sub === "serve" || sub === "http" || sub === "serve-http") {
41
+ const { startHttpServer } = await import("./http.js");
42
+ const flagIdx = process.argv.indexOf("--port");
43
+ const raw = (flagIdx !== -1 ? process.argv[flagIdx + 1] : undefined) ?? process.env.PORT;
44
+ const port = Number(raw);
45
+ await startHttpServer(Number.isFinite(port) && port > 0 ? port : 8787);
46
+ return;
47
+ }
30
48
  const transport = new StdioServerTransport();
31
49
  const server = createServer();
32
50
  await server.connect(transport);
@@ -1,6 +1,31 @@
1
- export function readConfig() {
2
- const base = process.env.WEBCAKE_API_BASE;
3
- const jwt = process.env.WEBCAKE_JWT;
1
+ /**
2
+ * Resolve the persistence config. Three sources, in priority order:
3
+ * 1. per-request `overrides` (remote/Streamable-HTTP mode: each client sends its
4
+ * OWN Webcake JWT via HTTP headers — see `configFromHeaders` — so a hosted
5
+ * server is multi-user and never bakes a shared secret into env), then
6
+ * 2. environment variables (stdio / single-user mode), then
7
+ * 3. the saved credentials file written by `webcake-landing-mcp login`
8
+ * (~/.webcake-landing-mcp/auth.json) — so a user can connect once via the
9
+ * browser instead of pasting a token.
10
+ *
11
+ * The JWT is never hard-coded (the repo is public). `readConfig` returns
12
+ * { config: null, missing } when required values are absent so the persistence
13
+ * tools can report exactly what to provide.
14
+ *
15
+ * WEBCAKE_API_BASE e.g. http://localhost:5800 (required to call the backend)
16
+ * WEBCAKE_JWT the account JWT (required to call the backend)
17
+ * WEBCAKE_ORG_ID optional default organization id for create_page
18
+ * WEBCAKE_HOST optional Host header override (Phoenix routes by host)
19
+ * WEBCAKE_APP_BASE optional base for editor/preview URLs in the result
20
+ * WEBCAKE_CONFIG_DIR optional dir for the saved auth.json (default ~/.webcake-landing-mcp)
21
+ */
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
25
+ export function readConfig(overrides = {}) {
26
+ const saved = readSavedConfig();
27
+ const base = overrides.base ?? process.env.WEBCAKE_API_BASE ?? saved.base;
28
+ const jwt = overrides.jwt ?? process.env.WEBCAKE_JWT ?? saved.jwt;
4
29
  const missing = [];
5
30
  if (!base)
6
31
  missing.push("WEBCAKE_API_BASE");
@@ -12,10 +37,60 @@ export function readConfig() {
12
37
  config: {
13
38
  base: base.replace(/\/+$/, ""),
14
39
  jwt: jwt,
15
- orgId: process.env.WEBCAKE_ORG_ID,
16
- host: process.env.WEBCAKE_HOST,
17
- appBase: process.env.WEBCAKE_APP_BASE?.replace(/\/+$/, ""),
40
+ orgId: overrides.orgId ?? process.env.WEBCAKE_ORG_ID ?? saved.orgId,
41
+ host: overrides.host ?? process.env.WEBCAKE_HOST ?? saved.host,
42
+ appBase: (overrides.appBase ?? process.env.WEBCAKE_APP_BASE ?? saved.appBase)?.replace(/\/+$/, ""),
18
43
  },
19
44
  missing: [],
20
45
  };
21
46
  }
47
+ function header(headers, name) {
48
+ const v = headers?.[name];
49
+ return Array.isArray(v) ? v[0] : v ?? undefined;
50
+ }
51
+ /**
52
+ * Build request-scoped config overrides from HTTP headers. Lets a remote client
53
+ * send its own credentials per request instead of a server-wide env token:
54
+ * x-webcake-jwt the account JWT (or `Authorization: Bearer <jwt>`)
55
+ * x-webcake-org-id organization id
56
+ * x-webcake-api-base backend base URL (usually set once via env instead)
57
+ * x-webcake-host Host header override
58
+ * x-webcake-app-base editor/preview URL base
59
+ * Any header that is absent falls back to the corresponding env var in readConfig.
60
+ */
61
+ export function configFromHeaders(headers) {
62
+ const auth = header(headers, "authorization");
63
+ const bearer = auth && /^Bearer\s+/i.test(auth) ? auth.replace(/^Bearer\s+/i, "").trim() : undefined;
64
+ return {
65
+ base: header(headers, "x-webcake-api-base"),
66
+ jwt: header(headers, "x-webcake-jwt") ?? bearer,
67
+ orgId: header(headers, "x-webcake-org-id"),
68
+ host: header(headers, "x-webcake-host"),
69
+ appBase: header(headers, "x-webcake-app-base"),
70
+ };
71
+ }
72
+ /** Directory for the saved auth file (override with WEBCAKE_CONFIG_DIR). */
73
+ export function configDir() {
74
+ return process.env.WEBCAKE_CONFIG_DIR || join(homedir(), ".webcake-landing-mcp");
75
+ }
76
+ export function savedConfigPath() {
77
+ return join(configDir(), "auth.json");
78
+ }
79
+ /** Read the saved credentials; {} when the file is absent or unreadable. */
80
+ export function readSavedConfig() {
81
+ try {
82
+ const parsed = JSON.parse(readFileSync(savedConfigPath(), "utf8"));
83
+ return parsed && typeof parsed === "object" ? parsed : {};
84
+ }
85
+ catch {
86
+ return {};
87
+ }
88
+ }
89
+ /** Merge + persist credentials to the saved file (0600). Returns the path written. */
90
+ export function saveSavedConfig(partial) {
91
+ const dir = configDir();
92
+ mkdirSync(dir, { recursive: true });
93
+ const path = savedConfigPath();
94
+ writeFileSync(path, JSON.stringify({ ...readSavedConfig(), ...partial }, null, 2), { mode: 0o600 });
95
+ return path;
96
+ }
@@ -4,21 +4,27 @@
4
4
  * default to dry_run=true and return a JWT-redacted request preview; they only
5
5
  * hit the network when dry_run===false. Validation uses the injected Domain;
6
6
  * the HTTP calls go through the Webcake client.
7
+ *
8
+ * Credentials resolve per request: in remote/Streamable-HTTP mode each call's
9
+ * headers (extra.requestInfo.headers) carry the client's own Webcake JWT, so a
10
+ * hosted server is multi-user; in stdio/single-user mode they come from env.
7
11
  */
8
12
  import { z } from "zod";
9
13
  import { text } from "../mcp/response.js";
10
- import { readConfig } from "../persistence/config.js";
14
+ import { readConfig, configFromHeaders } from "../persistence/config.js";
11
15
  import { buildRequestRedacted, buildUpdateRequestRedacted, createPage, listOrganizations, listPages, getPageSource, updatePageSource, } from "../persistence/webcake-client.js";
12
16
  export function registerPersistenceTools(server, domain) {
17
+ // Resolve config from THIS request's headers (remote per-user JWT) first, then env.
18
+ const cfgFor = (extra) => readConfig(configFromHeaders(extra?.requestInfo?.headers));
13
19
  // 8) List organizations -----------------------------------------------------
14
- server.tool("list_organizations", "List the account's Webcake organizations (id, name, is_default). The default org (type===1, usually the personal workspace) is where pages normally go. Call this BEFORE create_page, show the options to the user and ask which org to use — defaulting to the is_default one. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {}, async () => {
15
- const { config, missing } = readConfig();
20
+ server.tool("list_organizations", "List the account's Webcake organizations (id, name, is_default). The default org (type===1, usually the personal workspace) is where pages normally go. Call this BEFORE create_page, show the options to the user and ask which org to use — defaulting to the is_default one. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {}, async (_args, extra) => {
21
+ const { config, missing } = cfgFor(extra);
16
22
  if (!config) {
17
23
  return text({
18
24
  ok: false,
19
25
  reason: "missing_env",
20
26
  missing_env: missing,
21
- hint: "Configure WEBCAKE_API_BASE and WEBCAKE_JWT in the MCP server env.",
27
+ hint: "Configure WEBCAKE_API_BASE and WEBCAKE_JWT in the MCP server env (stdio), or send the JWT via the x-webcake-jwt header (remote).",
22
28
  });
23
29
  }
24
30
  return text(await listOrganizations(config));
@@ -37,7 +43,7 @@ export function registerPersistenceTools(server, domain) {
37
43
  .boolean()
38
44
  .optional()
39
45
  .describe("Default TRUE — preview the request without sending. Set false to actually create."),
40
- }, async ({ source, name, organization_id, dry_run }) => {
46
+ }, async ({ source, name, organization_id, dry_run }, extra) => {
41
47
  const pageName = name ?? "AI Page";
42
48
  const isDry = dry_run !== false; // default true (safe)
43
49
  const orgId = organization_id != null ? `${organization_id}` : undefined;
@@ -52,7 +58,7 @@ export function registerPersistenceTools(server, domain) {
52
58
  });
53
59
  }
54
60
  const parsed = domain.coerce(source);
55
- const { config, missing } = readConfig();
61
+ const { config, missing } = cfgFor(extra);
56
62
  if (isDry) {
57
63
  return text({
58
64
  dry_run: true,
@@ -63,7 +69,7 @@ export function registerPersistenceTools(server, domain) {
63
69
  request: config
64
70
  ? buildRequestRedacted(config, pageName, parsed, orgId)
65
71
  : {
66
- note: "Set WEBCAKE_API_BASE + WEBCAKE_JWT to enable real creation. Would POST to {WEBCAKE_API_BASE}/api/v1/ai/create_page_from_source.",
72
+ note: "Set WEBCAKE_API_BASE + WEBCAKE_JWT (env) or send the x-webcake-jwt header to enable real creation. Would POST to {WEBCAKE_API_BASE}/api/v1/ai/create_page_from_source.",
67
73
  },
68
74
  hint: "Re-run with dry_run=false to actually create the page.",
69
75
  });
@@ -73,22 +79,22 @@ export function registerPersistenceTools(server, domain) {
73
79
  created: false,
74
80
  reason: "missing_env",
75
81
  missing_env: missing,
76
- hint: "Configure WEBCAKE_API_BASE and WEBCAKE_JWT in the MCP server env, then retry.",
82
+ hint: "Configure WEBCAKE_API_BASE and WEBCAKE_JWT (env), or send the x-webcake-jwt header (remote), then retry.",
77
83
  });
78
84
  }
79
85
  const outcome = await createPage(config, pageName, parsed, orgId);
80
86
  return text({ created: outcome.ok, ...outcome, warnings: result.warnings });
81
87
  });
82
88
  // 10) List pages ------------------------------------------------------------
83
- server.tool("list_pages", "List the pages owned by the account (id, name, organization_id, updated_at), most-recent first. Use it to let the user pick a page to edit (then get_page → modify → update_page). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {}, async () => {
84
- const { config, missing } = readConfig();
89
+ server.tool("list_pages", "List the pages owned by the account (id, name, organization_id, updated_at), most-recent first. Use it to let the user pick a page to edit (then get_page → modify → update_page). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {}, async (_args, extra) => {
90
+ const { config, missing } = cfgFor(extra);
85
91
  if (!config)
86
92
  return text({ ok: false, reason: "missing_env", missing_env: missing });
87
93
  return text(await listPages(config));
88
94
  });
89
95
  // 11) Get page (read source) ------------------------------------------------
90
- server.tool("get_page", "Fetch an existing page's decoded source tree { page, popup, settings, options, cartConfigs } so you can EDIT it. Returns name + organization_id too. Edit the returned `source`, then validate_page and update_page. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", { page_id: z.string().describe("The page id (from list_pages or a URL).") }, async ({ page_id }) => {
91
- const { config, missing } = readConfig();
96
+ server.tool("get_page", "Fetch an existing page's decoded source tree { page, popup, settings, options, cartConfigs } so you can EDIT it. Returns name + organization_id too. Edit the returned `source`, then validate_page and update_page. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", { page_id: z.string().describe("The page id (from list_pages or a URL).") }, async ({ page_id }, extra) => {
97
+ const { config, missing } = cfgFor(extra);
92
98
  if (!config)
93
99
  return text({ ok: false, reason: "missing_env", missing_env: missing });
94
100
  return text(await getPageSource(config, page_id));
@@ -100,7 +106,7 @@ export function registerPersistenceTools(server, domain) {
100
106
  .any()
101
107
  .describe("The full edited page source { page, popup, settings, options, cartConfigs } (object or JSON string)."),
102
108
  dry_run: z.boolean().optional().describe("Default TRUE — preview without sending. Set false to actually save."),
103
- }, async ({ page_id, source, dry_run }) => {
109
+ }, async ({ page_id, source, dry_run }, extra) => {
104
110
  const isDry = dry_run !== false;
105
111
  const result = domain.validate(source);
106
112
  if (!result.valid) {
@@ -113,7 +119,7 @@ export function registerPersistenceTools(server, domain) {
113
119
  });
114
120
  }
115
121
  const parsed = domain.coerce(source);
116
- const { config, missing } = readConfig();
122
+ const { config, missing } = cfgFor(extra);
117
123
  if (isDry) {
118
124
  return text({
119
125
  dry_run: true,
@@ -123,7 +129,7 @@ export function registerPersistenceTools(server, domain) {
123
129
  missing_env: missing,
124
130
  request: config
125
131
  ? buildUpdateRequestRedacted(config, page_id, parsed)
126
- : { note: "Set WEBCAKE_API_BASE + WEBCAKE_JWT to enable real updates." },
132
+ : { note: "Set WEBCAKE_API_BASE + WEBCAKE_JWT (env) or send the x-webcake-jwt header to enable real updates." },
127
133
  hint: "Re-run with dry_run=false to actually save the edit.",
128
134
  });
129
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "MCP server exposing Webcake landing-page element schemas + AI usage hints, and persisting LLM-generated page sources to a Webcake backend.",
5
5
  "type": "module",
6
6
  "bin": {