@sakupa/mcp 0.1.0 → 0.3.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/dist/bin.js +188 -211
  2. package/dist/index.js +240 -263
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,59 +1,7 @@
1
1
  // src/version.ts
2
- var MCP_VERSION = "0.1.0";
2
+ var MCP_VERSION = "0.3.0";
3
3
  var CLIENT_TYPE = "sakupa-mcp";
4
4
 
5
- // src/transport.ts
6
- var FetchTransport = class {
7
- baseUrl;
8
- constructor(baseUrl) {
9
- this.baseUrl = baseUrl.replace(/\/+$/, "");
10
- }
11
- async request(req) {
12
- let url = `${this.baseUrl}${req.path}`;
13
- if (req.query && Object.keys(req.query).length > 0) {
14
- url += `?${new URLSearchParams(req.query).toString()}`;
15
- }
16
- const headers = {
17
- accept: "application/json",
18
- ...req.body !== void 0 ? { "content-type": "application/json" } : {},
19
- ...req.headers
20
- };
21
- const res = await fetch(url, {
22
- method: req.method,
23
- headers,
24
- ...req.body !== void 0 ? { body: req.body } : {}
25
- });
26
- const text2 = await res.text();
27
- const responseHeaders = {};
28
- res.headers.forEach((value, key) => {
29
- responseHeaders[key] = value;
30
- });
31
- return {
32
- status: res.status,
33
- headers: responseHeaders,
34
- ...text2.length > 0 ? { body: text2 } : {}
35
- };
36
- }
37
- async upload(target, body) {
38
- if (target.url.startsWith("memory://")) {
39
- throw new Error(
40
- `Upload target "${target.url}" is an in-process memory URL. memory:// targets only exist inside the in-process test harness and cannot be uploaded to over HTTP.`
41
- );
42
- }
43
- const res = await fetch(target.url, {
44
- method: target.method,
45
- headers: target.headers,
46
- body
47
- });
48
- if (!res.ok) {
49
- const text2 = await res.text().catch(() => "");
50
- throw new Error(
51
- `Upload of "${target.path}" failed with HTTP ${res.status}${text2 ? `: ${text2.slice(0, 200)}` : ""}`
52
- );
53
- }
54
- }
55
- };
56
-
57
5
  // ../core/dist/domain/constants.js
58
6
  var SERVICE_DOMAIN = "sakupa.com";
59
7
  var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
@@ -186,11 +134,11 @@ var HTTP_STATUS = {
186
134
  conflict: 409,
187
135
  state_conflict: 409,
188
136
  rate_limited: 429,
189
- insufficient_balance: 402,
190
137
  payment_required: 402,
191
138
  confirmation_required: 428,
192
139
  dns_not_verified: 403,
193
140
  not_deployable: 422,
141
+ upgrade_required: 426,
194
142
  internal: 500
195
143
  };
196
144
  var SakupaError = class extends Error {
@@ -490,17 +438,80 @@ function safeDecode(bytes) {
490
438
  }
491
439
  }
492
440
 
441
+ // ../core/dist/domain/hashing.js
442
+ async function sha256Hex(bytes) {
443
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
444
+ let hex = "";
445
+ for (const byte of new Uint8Array(digest))
446
+ hex += byte.toString(16).padStart(2, "0");
447
+ return hex;
448
+ }
449
+
493
450
  // ../core/dist/domain/subscription.js
494
- var SUBSCRIPTION_WARNING_TEXT = "You are subscribing {billingDomain} to Sakupa Domain Hosting: the {plan} monthly plan (\xA5{priceJpy}/month).\n\nPaying does not prove that you own this domain, does not grant management rights, and does not by itself publish a website: the domain must still pass DNS ownership verification.\n\nIf this site outgrows its plan, Sakupa automatically upgrades the subscription to the next plan (water -> personal -> share -> business) and renewals bill the new plan. There is no metered overage: above the Business plan, growth is limited instead of billed further.\n\nYou can cancel anytime; hosting then runs to the end of the already-paid month and stops.";
451
+ var SUBSCRIPTION_WARNING_TEXT = "You are subscribing this site ({siteUrl}) to Sakupa Hosting: the {plan} monthly plan (\xA5{priceJpy}/month).\n\nPaying makes THIS site permanent on its {siteUrl} address \u2014 it stops expiring. Binding a custom domain afterwards is an optional included extra: it requires proving DNS control of that domain, and whether or not you ever bind one does not change the subscription or qualify for a refund.\n\nIf this site outgrows its plan, Sakupa automatically upgrades the subscription to the next plan (water -> personal -> share -> business) and renewals bill the new plan. There is no metered overage: above the Business plan, growth is limited instead of billed further.\n\nYou can cancel anytime; when the subscription ends, the site immediately becomes a free temporary site again (24h validity) and all paid data \u2014 custom domain bindings included \u2014 is removed.";
495
452
 
496
453
  // ../core/dist/dto.js
497
454
  var CREDENTIAL_HEADER = "x-sakupa-credential";
498
455
  var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
456
+ var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
499
457
 
500
458
  // ../core/dist/services/sites.js
501
459
  var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
502
460
  var utf8Encoder = new TextEncoder();
503
461
 
462
+ // src/transport.ts
463
+ var FetchTransport = class {
464
+ baseUrl;
465
+ constructor(baseUrl) {
466
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
467
+ }
468
+ async request(req) {
469
+ let url = `${this.baseUrl}${req.path}`;
470
+ if (req.query && Object.keys(req.query).length > 0) {
471
+ url += `?${new URLSearchParams(req.query).toString()}`;
472
+ }
473
+ const headers = {
474
+ accept: "application/json",
475
+ [MCP_VERSION_HEADER]: MCP_VERSION,
476
+ ...req.body !== void 0 ? { "content-type": "application/json" } : {},
477
+ ...req.headers
478
+ };
479
+ const res = await fetch(url, {
480
+ method: req.method,
481
+ headers,
482
+ ...req.body !== void 0 ? { body: req.body } : {}
483
+ });
484
+ const text2 = await res.text();
485
+ const responseHeaders = {};
486
+ res.headers.forEach((value, key) => {
487
+ responseHeaders[key] = value;
488
+ });
489
+ return {
490
+ status: res.status,
491
+ headers: responseHeaders,
492
+ ...text2.length > 0 ? { body: text2 } : {}
493
+ };
494
+ }
495
+ async upload(target, body) {
496
+ if (target.url.startsWith("memory://")) {
497
+ throw new Error(
498
+ `Upload target "${target.url}" is an in-process memory URL. memory:// targets only exist inside the in-process test harness and cannot be uploaded to over HTTP.`
499
+ );
500
+ }
501
+ const res = await fetch(target.url, {
502
+ method: target.method,
503
+ headers: target.headers,
504
+ body
505
+ });
506
+ if (!res.ok) {
507
+ const text2 = await res.text().catch(() => "");
508
+ throw new Error(
509
+ `Upload of "${target.path}" failed with HTTP ${res.status}${text2 ? `: ${text2.slice(0, 200)}` : ""}`
510
+ );
511
+ }
512
+ }
513
+ };
514
+
504
515
  // src/api-client.ts
505
516
  var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
506
517
  "invalid_request",
@@ -511,11 +522,11 @@ var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
511
522
  "conflict",
512
523
  "state_conflict",
513
524
  "rate_limited",
514
- "insufficient_balance",
515
525
  "payment_required",
516
526
  "confirmation_required",
517
527
  "dns_not_verified",
518
528
  "not_deployable",
529
+ "upgrade_required",
519
530
  "internal"
520
531
  ]);
521
532
  var HttpApiClient = class {
@@ -616,14 +627,7 @@ var HttpApiClient = class {
616
627
  { body: req }
617
628
  );
618
629
  }
619
- /** Wallet-era recharge checkout; unused (kept for SakupaApiClient compatibility). */
620
- async createCheckout(req) {
621
- return this.call("POST", "/v1/payments/checkout", {
622
- body: req,
623
- idempotencyKey: req.idempotencyKey
624
- });
625
- }
626
- /** Subscription checkout: a fixed monthly plan for one apex domain (owner-only). */
630
+ /** Subscription checkout: a fixed monthly plan permanentizing one site (owner-only). */
627
631
  async createPlanCheckout(req, credential) {
628
632
  return this.call("POST", "/v1/payments/checkout", {
629
633
  credential,
@@ -632,31 +636,24 @@ var HttpApiClient = class {
632
636
  });
633
637
  }
634
638
  /** Stripe-hosted billing portal (payment method, invoices, cancellation). */
635
- async createBillingPortal(apexDomain, credential) {
639
+ async createBillingPortal(siteId, credential) {
636
640
  return this.call(
637
641
  "POST",
638
- `/v1/billing/${encodeURIComponent(apexDomain)}/portal`,
642
+ `/v1/sites/${encodeURIComponent(siteId)}/billing/portal`,
639
643
  { credential, body: {} }
640
644
  );
641
645
  }
642
- async getBillingStatus(apexDomain, credential) {
646
+ async getBillingStatus(siteId, credential) {
643
647
  return this.call(
644
648
  "GET",
645
- `/v1/billing/${encodeURIComponent(apexDomain)}`,
646
- credential !== void 0 ? { credential } : {}
647
- );
648
- }
649
- async setBillingPlan(apexDomain, credential, req) {
650
- return this.call(
651
- "POST",
652
- `/v1/billing/${encodeURIComponent(apexDomain)}/plan`,
653
- { credential, body: req }
649
+ `/v1/sites/${encodeURIComponent(siteId)}/billing`,
650
+ { credential }
654
651
  );
655
652
  }
656
- async downgradeSite(siteId, credential, req) {
653
+ async setBillingPlan(siteId, credential, req) {
657
654
  return this.call(
658
655
  "POST",
659
- `/v1/sites/${encodeURIComponent(siteId)}/downgrade`,
656
+ `/v1/sites/${encodeURIComponent(siteId)}/billing/plan`,
660
657
  { credential, body: req }
661
658
  );
662
659
  }
@@ -702,8 +699,7 @@ function readSiteFile(projectDir) {
702
699
  apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : "",
703
700
  ...typeof parsed.shortId === "string" ? { shortId: parsed.shortId } : {},
704
701
  ...typeof parsed.url === "string" ? { url: parsed.url } : {},
705
- ...typeof parsed.billingDomain === "string" ? { billingDomain: parsed.billingDomain } : {},
706
- ...typeof parsed.billingDomainId === "string" ? { billingDomainId: parsed.billingDomainId } : {}
702
+ ...typeof parsed.boundDomain === "string" ? { boundDomain: parsed.boundDomain } : {}
707
703
  };
708
704
  } catch {
709
705
  return null;
@@ -1194,8 +1190,8 @@ var severityEnum = z.enum(["low", "medium", "high", "critical"]);
1194
1190
  function planCatalog() {
1195
1191
  return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
1196
1192
  }
1197
- function subscriptionWarning(domain, plan) {
1198
- return SUBSCRIPTION_WARNING_TEXT.replaceAll("{billingDomain}", domain).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
1193
+ function subscriptionWarning(siteUrl, plan) {
1194
+ return SUBSCRIPTION_WARNING_TEXT.replaceAll("{siteUrl}", siteUrl).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
1199
1195
  }
1200
1196
  var ticketCategoryEnum = z.enum([
1201
1197
  "billing",
@@ -1234,6 +1230,36 @@ Please ask the user to choose, then re-run deploy_site with:
1234
1230
  Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`
1235
1231
  );
1236
1232
  }
1233
+ var MB2 = 1024 * 1024;
1234
+ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
1235
+ const oversized = manifest.find((f) => f.size > MAX_SINGLE_FILE_BYTES);
1236
+ if (oversized) {
1237
+ throw new SakupaError(
1238
+ "validation_failed",
1239
+ `"${oversized.path}" is ${(oversized.size / MB2).toFixed(1)}MB, over the ${Math.floor(MAX_SINGLE_FILE_BYTES / MB2)}MB per-file limit. Remove or shrink it before deploying.`
1240
+ );
1241
+ }
1242
+ if (isFirstFreeDeploy) {
1243
+ const total = manifest.reduce((sum, f) => sum + f.size, 0);
1244
+ if (total > FREE_SITE_MAX_TOTAL_BYTES) {
1245
+ throw new SakupaError(
1246
+ "validation_failed",
1247
+ `The static output is ${(total / MB2).toFixed(1)}MB, over the ${Math.floor(FREE_SITE_MAX_TOTAL_BYTES / MB2)}MB free-site limit. Reduce the output (bundle/compress assets, drop large media) or subscribe the site to a paid plan for larger sites.`
1248
+ );
1249
+ }
1250
+ }
1251
+ }
1252
+ async function buildHashedManifest(files, outputAbs) {
1253
+ const manifest = [];
1254
+ for (const file of files) {
1255
+ const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, file.path)));
1256
+ manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
1257
+ }
1258
+ return manifest;
1259
+ }
1260
+ function isIncrementalReuseFailure(e) {
1261
+ return isSakupaError(e) && e.code === "state_conflict" && typeof e.details === "object" && e.details !== null && e.details.incrementalReuseFailed === true;
1262
+ }
1237
1263
  async function uploadAll(ctx, targets, files, outputAbs) {
1238
1264
  for (let i = 0; i < targets.length; i++) {
1239
1265
  const target = targets[i];
@@ -1246,6 +1272,12 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1246
1272
  );
1247
1273
  }
1248
1274
  const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, match.path)));
1275
+ if (bytes.byteLength !== match.size) {
1276
+ throw new SakupaError(
1277
+ "validation_failed",
1278
+ `"${match.path}" changed since analysis (${match.size} -> ${bytes.byteLength} bytes). Re-run deploy_site so Sakupa re-checks the current files before uploading.`
1279
+ );
1280
+ }
1249
1281
  await ctx.client.uploadFile(target, bytes);
1250
1282
  }
1251
1283
  return targets.length;
@@ -1281,7 +1313,7 @@ Next action: ${analysis.suggestedNextAction}`,
1281
1313
  server.registerTool(
1282
1314
  "deploy_site",
1283
1315
  {
1284
- description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://{shortId}.sakupa.com) and stores the management credential in .sakupa/site.json. Later runs update the existing site and refresh its validity. Runs analyze_site first and refuses to upload source projects, secrets, .env files, archives, media or server code. Never uploads anything when the analysis says the project is not deployable.`,
1316
+ description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://{shortId}.sakupa.com) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscribed sites are permanent). Runs analyze_site first and refuses to upload source projects, secrets, .env files, archives, media or server code. Never uploads anything when the analysis says the project is not deployable.`,
1285
1317
  inputSchema: {
1286
1318
  outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1287
1319
  spaFallback: z.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
@@ -1303,9 +1335,10 @@ Next action: ${analysis.suggestedNextAction}`,
1303
1335
  return notDeployableResult(analysis);
1304
1336
  }
1305
1337
  const files = analysis.files;
1306
- const manifest = files.map((f) => ({ path: f.path, size: f.size }));
1307
1338
  const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1339
+ const manifest = await buildHashedManifest(files, outputAbs);
1308
1340
  const existing = readSiteFile(ctx.projectDir);
1341
+ ensureUploadSizeWithinLimits(manifest, !existing);
1309
1342
  if (!existing) {
1310
1343
  const created = await ctx.client.createSite({
1311
1344
  manifest,
@@ -1332,32 +1365,49 @@ Next action: ${analysis.suggestedNextAction}`,
1332
1365
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1333
1366
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
1334
1367
  ` : "") + `
1335
- This is a FREE temporary preview: it stays live for ${FREE_SITE_TTL_HOURS} hours. Deploying again or calling refresh_site extends the validity. The management credential was saved to .sakupa/site.json \u2014 keep that file: it is the only way to manage this free site.
1368
+ This is a FREE temporary preview: it stays live for ${FREE_SITE_TTL_HOURS} hours. Deploying again or calling refresh_site extends the validity; subscribing the site (subscribe_site) makes this URL permanent. The management credential was saved to .sakupa/site.json \u2014 keep that file: it is the only way to manage this site.
1336
1369
  ` + (finalized2.warnings.length > 0 ? `
1337
1370
  Warnings:
1338
1371
  ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
1339
1372
  );
1340
1373
  }
1341
- const deployment = await ctx.client.createDeployment(existing.siteId, existing.credential, {
1342
- manifest,
1343
- ...args.lang !== void 0 ? { lang: args.lang } : {},
1344
- spaFallback: args.spaFallback === true,
1345
- ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1346
- });
1347
- const uploaded = await uploadAll(ctx, deployment.uploadTargets, files, outputAbs);
1348
- const finalized = await ctx.client.finalizeDeployment(
1349
- deployment.deploymentId,
1350
- existing.siteId,
1351
- existing.credential
1352
- );
1374
+ const updateOnce = async (forceFullUpload) => {
1375
+ const req = {
1376
+ manifest,
1377
+ ...args.lang !== void 0 ? { lang: args.lang } : {},
1378
+ spaFallback: args.spaFallback === true,
1379
+ ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {},
1380
+ ...forceFullUpload ? { forceFullUpload: true } : {}
1381
+ };
1382
+ const deployment = await ctx.client.createDeployment(
1383
+ existing.siteId,
1384
+ existing.credential,
1385
+ req
1386
+ );
1387
+ const uploaded2 = await uploadAll(ctx, deployment.uploadTargets, files, outputAbs);
1388
+ const finalized2 = await ctx.client.finalizeDeployment(
1389
+ deployment.deploymentId,
1390
+ existing.siteId,
1391
+ existing.credential
1392
+ );
1393
+ return { uploaded: uploaded2, finalized: finalized2 };
1394
+ };
1395
+ let update;
1396
+ try {
1397
+ update = await updateOnce(false);
1398
+ } catch (e) {
1399
+ if (!isIncrementalReuseFailure(e)) throw e;
1400
+ update = await updateOnce(true);
1401
+ }
1402
+ const { uploaded, finalized } = update;
1353
1403
  writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
1354
1404
  return text(
1355
1405
  `Site updated: ${finalized.url}
1356
1406
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1357
1407
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
1358
1408
  ` : "") + (finalized.mode === "free" ? `
1359
- Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh_site call.
1360
- ` : "") + (finalized.warnings.length > 0 ? `
1409
+ Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh_site call. Subscribing (subscribe_site) makes the site permanent.
1410
+ ` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
1361
1411
  Warnings:
1362
1412
  ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1363
1413
  );
@@ -1369,7 +1419,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1369
1419
  server.registerTool(
1370
1420
  "refresh_site",
1371
1421
  {
1372
- description: "Refresh the validity of the free temporary site WITHOUT uploading content. Uses the local credential in .sakupa/site.json.",
1422
+ description: "Refresh the validity of the free temporary site WITHOUT uploading content. Uses the local credential in .sakupa/site.json. Subscribed sites are permanent and need no refresh.",
1373
1423
  inputSchema: {}
1374
1424
  },
1375
1425
  async () => {
@@ -1388,7 +1438,7 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1388
1438
  server.registerTool(
1389
1439
  "site_status",
1390
1440
  {
1391
- description: "Show the current status of this project's Sakupa site: URL, mode (free/paid), expiry, domain binding, size, last deployment and warnings.",
1441
+ description: "Show the current status of this project's Sakupa site: URL, mode (free/paid), expiry, subscription state, custom domains, size, last deployment and warnings.",
1392
1442
  inputSchema: {}
1393
1443
  },
1394
1444
  async () => {
@@ -1401,10 +1451,53 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1401
1451
  }
1402
1452
  }
1403
1453
  );
1454
+ server.registerTool(
1455
+ "subscribe_site",
1456
+ {
1457
+ description: `Create a Stripe Checkout link that subscribes THIS site to a Sakupa Hosting monthly plan (${planCatalog()}). Paying makes the site PERMANENT on its {shortId}.sakupa.com URL \u2014 no more 24h expiry; that is the core value of paying. Binding a custom domain afterwards (bind_domain) is an optional included extra and requires DNS control of that domain. Owner-only: requires this project's site credential (.sakupa/site.json) \u2014 deploy_site first. If the site outgrows its plan, Sakupa auto-upgrades to the next plan (capped at Business, where growth is limited instead of billed further). Card details are entered only on the Stripe-hosted page \u2014 never through the AI tool. Without confirm: true this tool only shows the mandatory disclosure and makes no API call.`,
1458
+ inputSchema: {
1459
+ plan: planEnum.describe(
1460
+ "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
1461
+ ),
1462
+ confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
1463
+ }
1464
+ },
1465
+ async (args) => {
1466
+ try {
1467
+ const site = requireSiteFile(ctx);
1468
+ const siteUrl = site.url ?? (site.shortId ? `https://${site.shortId}.sakupa.com` : site.siteId);
1469
+ if (args.confirm !== true) {
1470
+ return text(
1471
+ `${subscriptionWarning(siteUrl, args.plan)}
1472
+
1473
+ No checkout link was created yet. Please show this disclosure to the user and, after their explicit confirmation, re-run subscribe_site with confirm: true.`
1474
+ );
1475
+ }
1476
+ const res = await ctx.client.createPlanCheckout(
1477
+ {
1478
+ siteId: site.siteId,
1479
+ plan: args.plan,
1480
+ idempotencyKey: randomUUID(),
1481
+ confirmPlan: true
1482
+ },
1483
+ site.credential
1484
+ );
1485
+ return text(
1486
+ `Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
1487
+ ${res.checkoutUrl}
1488
+
1489
+ Open this link in a browser to subscribe. Card data is entered only on the Stripe-hosted page \u2014 never give card numbers, passwords or security codes to the AI tool.
1490
+ Once payment confirms, the site becomes permanent on its current URL. Binding a custom domain (bind_domain) is optional and still requires DNS verification.`
1491
+ );
1492
+ } catch (e) {
1493
+ return toolError(e);
1494
+ }
1495
+ }
1496
+ );
1404
1497
  server.registerTool(
1405
1498
  "bind_domain",
1406
1499
  {
1407
- description: `Start or continue binding a custom domain to this site (the paid long-term mode). Ownership is proven ONLY by DNS control of the registered apex domain \u2014 payment never grants ownership. The billing subject is always the apex domain: all its subdomains share one hosting subscription (plans: ${planCatalog()}). The apex domain needs an ACTIVE subscription (see subscribe_domain) before paid configuration can start. Call again with verificationId to check DNS verification progress.`,
1500
+ description: "Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent {shortId}.sakupa.com URL keeps working alongside it. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the registered apex domain \u2014 payment never grants ownership. A binding request never reserves the hostname: whoever proves DNS control first gets it, and unverified requests expire after 72 hours. Call again with verificationId to check progress.",
1408
1501
  inputSchema: {
1409
1502
  hostname: z.string().describe('Hostname to bind, e.g. "example.com" or "www.example.com".'),
1410
1503
  verificationId: z.string().optional().describe("Check an existing DNS verification instead of starting a new one.")
@@ -1416,12 +1509,12 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1416
1509
  if (args.verificationId !== void 0) {
1417
1510
  const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
1418
1511
  if (res2.status === "verified") {
1419
- writeSiteFile(ctx.projectDir, { ...site, billingDomain: res2.apexDomain });
1512
+ writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
1420
1513
  }
1421
1514
  return text(
1422
1515
  `DNS verification ${res2.verificationId}: ${res2.status}
1423
1516
  ${res2.message}
1424
- ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificate and CDN setup are in progress; check again with bind_domain + verificationId later.
1517
+ ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificate and serving setup are in progress; check again with bind_domain + verificationId later.
1425
1518
  ` : "") + (res2.pendingDnsRecords.length > 0 ? `
1426
1519
  DNS records still required:
1427
1520
  ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
@@ -1432,22 +1525,15 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
1432
1525
  hostname: args.hostname
1433
1526
  };
1434
1527
  const res = await ctx.client.bindDomain(site.credential, req);
1435
- writeSiteFile(ctx.projectDir, {
1436
- ...site,
1437
- billingDomain: res.apexDomain,
1438
- billingDomainId: res.billingDomainId
1439
- });
1440
1528
  const servingHint = res.isApex ? `"${res.hostname}" is an apex domain: your DNS provider must support ALIAS, ANAME, CNAME flattening, or alias records to point it at the assigned CDN domain.` : `"${res.hostname}" is a subdomain: create a CNAME record pointing it at the assigned CDN domain once provisioning completes.`;
1441
1529
  return text(
1442
1530
  `Domain binding started for ${res.hostname}.
1443
1531
 
1444
- Billing subject: the apex domain "${res.apexDomain}". Subdomains of ${res.apexDomain} cannot be separate billing subjects \u2014 they all share this domain hosting subscription.
1445
-
1446
1532
  1. Prove control of ${res.apexDomain} by creating this DNS record:
1447
1533
  name: ${res.verificationRecord.name}
1448
1534
  type: ${res.verificationRecord.type}
1449
1535
  value: ${res.verificationRecord.value}
1450
- Ownership and management authority come ONLY from DNS control. Paying for the subscription never grants ownership, management rights, or activation.
1536
+ Ownership comes ONLY from DNS control; paying never grants it. This request does not reserve the hostname \u2014 the first verified request wins, and this challenge expires after 72 hours.
1451
1537
 
1452
1538
  2. Serving DNS (after verification): ${servingHint}
1453
1539
  Server guidance: ${res.servingInstructions}
@@ -1462,33 +1548,24 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
1462
1548
  server.registerTool(
1463
1549
  "billing_status",
1464
1550
  {
1465
- description: "Show the hosting subscription status of an apex billing domain: current plan, payment state, current paid period, reconciled usage, estimated usage tier, next renewal, risks and the per-site usage breakdown. Reads are based on the latest reconciled snapshot.",
1466
- inputSchema: {
1467
- domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json.")
1468
- }
1551
+ description: "Show this site's hosting subscription: plan, payment state, current paid period, reconciled usage, estimated usage tier, bound custom domains and risks. Owner-only (uses the credential in .sakupa/site.json).",
1552
+ inputSchema: {}
1469
1553
  },
1470
- async (args) => {
1554
+ async () => {
1471
1555
  try {
1472
- const site = readSiteFile(ctx.projectDir);
1473
- const domain = args.domain ?? site?.billingDomain;
1474
- if (!domain) {
1475
- throw new SakupaError(
1476
- "invalid_request",
1477
- 'No domain given and this project has no bound billing domain. Pass domain, e.g. billing_status { domain: "example.com" }.'
1478
- );
1479
- }
1480
- const res = await ctx.client.getBillingStatus(domain, site?.credential);
1556
+ const site = requireSiteFile(ctx);
1557
+ const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
1481
1558
  const lines = [
1482
- `Billing status for ${res.paidSubject} (service status: ${res.serviceStatus})`,
1483
- res.committedTier ? `Plan: ${res.committedTier} (\xA5${tierPriceJpy(res.committedTier)}/month)` : "Plan: (no subscription yet)",
1559
+ `Billing status for site ${res.siteId} (mode: ${res.mode})`,
1560
+ res.permanentUrl ? `Permanent URL: ${res.permanentUrl}` : void 0,
1561
+ res.plan ? `Plan: ${res.plan} (\xA5${res.monthlyPriceJpy ?? tierPriceJpy(res.plan)}/month)` : "Plan: (no subscription yet)",
1484
1562
  res.subscriptionStatus ? `Subscription payment state: ${res.subscriptionStatus}` : void 0,
1485
- res.cancelAtCycleEnd ? "Renewal: CANCELED \u2014 hosting stops at the end of the already-paid month" : void 0,
1486
- res.currentCycleStart ? `Current paid period: ${res.currentCycleStart} -> ${res.currentCycleEnd ?? "?"}` : void 0,
1563
+ res.cancelAtPeriodEnd ? "Renewal: CANCELED \u2014 the site reverts to free at the end of the already-paid month" : void 0,
1564
+ res.currentPeriodStart ? `Current paid period: ${res.currentPeriodStart} -> ${res.currentPeriodEnd ?? "?"}` : void 0,
1487
1565
  res.estimatedUsageTier ? `Estimated usage tier: ${res.estimatedUsageTier}` : void 0,
1488
1566
  res.lastReconciledAt ? `Last usage reconciliation: ${res.lastReconciledAt}` : void 0,
1489
- res.nextSettlementAt ? `Next renewal: ${res.nextSettlementAt}` : void 0,
1490
- res.risks.unpaid ? "ATTENTION: renewal payment failed \u2014 update the payment method (manage_billing) to keep the custom domain hosted." : void 0,
1491
- res.ownerView ? void 0 : "Note: public view (no valid owner credential presented)."
1567
+ res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
1568
+ res.risks.pastDue ? "ATTENTION: renewal payment failing \u2014 update the payment method (manage_billing). Serving continues while Stripe retries; if Stripe gives up, the site reverts to free." : void 0
1492
1569
  ].filter((l) => l !== void 0);
1493
1570
  return textJson(`${lines.join("\n")}
1494
1571
 
@@ -1498,71 +1575,18 @@ Full status:`, res);
1498
1575
  }
1499
1576
  }
1500
1577
  );
1501
- server.registerTool(
1502
- "subscribe_domain",
1503
- {
1504
- description: `Create a Stripe Checkout link that subscribes an apex domain to a Sakupa Domain Hosting monthly plan (${planCatalog()}). Owner-only: requires this project's site credential (.sakupa/site.json) \u2014 deploy_site first; a lost credential means payment is not possible. One domain = one subscription; if the site outgrows its plan, Sakupa auto-upgrades to the next plan (capped at Business, where growth is limited instead of billed further). Paying never proves domain ownership and never activates a website \u2014 DNS verification (bind_domain) is still required. Card details are entered only on the Stripe-hosted page \u2014 never through the AI tool. Without confirm: true this tool only shows the mandatory disclosure and makes no API call.`,
1505
- inputSchema: {
1506
- domain: z.string().describe('Apex domain to subscribe, e.g. "example.com".'),
1507
- plan: planEnum.describe(
1508
- "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
1509
- ),
1510
- confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
1511
- }
1512
- },
1513
- async (args) => {
1514
- try {
1515
- const site = requireSiteFile(ctx);
1516
- if (args.confirm !== true) {
1517
- return text(
1518
- `${subscriptionWarning(args.domain, args.plan)}
1519
-
1520
- No checkout link was created yet. Please show this disclosure to the user and, after their explicit confirmation, re-run subscribe_domain with confirm: true.`
1521
- );
1522
- }
1523
- const res = await ctx.client.createPlanCheckout(
1524
- {
1525
- siteId: site.siteId,
1526
- domain: args.domain,
1527
- plan: args.plan,
1528
- idempotencyKey: randomUUID(),
1529
- confirmPlan: true
1530
- },
1531
- site.credential
1532
- );
1533
- return text(
1534
- `Stripe Checkout link \u2014 Sakupa Domain Hosting for ${res.apexDomain}: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
1535
- ${res.checkoutUrl}
1536
-
1537
- Open this link in a browser to subscribe. Card data is entered only on the Stripe-hosted page \u2014 never give card numbers, passwords or security codes to the AI tool.
1538
- Reminder: the subscription is payment state only. It grants no domain ownership and no management rights; the domain must still pass DNS verification (bind_domain) before long-term hosting activates.`
1539
- );
1540
- } catch (e) {
1541
- return toolError(e);
1542
- }
1543
- }
1544
- );
1545
1578
  server.registerTool(
1546
1579
  "manage_billing",
1547
1580
  {
1548
- description: "Open the Stripe-hosted billing portal for the bound apex domain: update the payment method, view invoices, or cancel the subscription. All billing operations happen on the Stripe-hosted page \u2014 never inside the AI tool. Requires the local site credential (.sakupa/site.json) of a site bound to the domain.",
1549
- inputSchema: {
1550
- domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json.")
1551
- }
1581
+ description: "Open the Stripe-hosted billing portal for this site: update the payment method, view invoices, or cancel the subscription. All billing operations happen on the Stripe-hosted page \u2014 never inside the AI tool. Requires the local site credential (.sakupa/site.json).",
1582
+ inputSchema: {}
1552
1583
  },
1553
- async (args) => {
1584
+ async () => {
1554
1585
  try {
1555
1586
  const site = requireSiteFile(ctx);
1556
- const domain = args.domain ?? site.billingDomain;
1557
- if (!domain) {
1558
- throw new SakupaError(
1559
- "invalid_request",
1560
- "No domain given and this project has no bound billing domain. Pass domain explicitly."
1561
- );
1562
- }
1563
- const res = await ctx.client.createBillingPortal(domain, site.credential);
1587
+ const res = await ctx.client.createBillingPortal(site.siteId, site.credential);
1564
1588
  return text(
1565
- `Stripe billing portal for ${res.apexDomain}:
1589
+ `Stripe billing portal for this site:
1566
1590
  ${res.portalUrl}
1567
1591
 
1568
1592
  Open this link in a browser to update the payment method, view invoices, or manage the subscription. The link is temporary \u2014 create a fresh one when needed.`
@@ -1575,7 +1599,7 @@ Open this link in a browser to update the payment method, view invoices, or mana
1575
1599
  server.registerTool(
1576
1600
  "recover_domain_site",
1577
1601
  {
1578
- description: "Recover management control of a paid custom-domain site after losing the local project, by proving DNS control of the apex domain. By default, completing recovery REVOKES all previous local credentials for the site. Call first with the hostname to get the DNS record, then again with verificationId to complete recovery (writes a new .sakupa/site.json and returns a download link for the current site content).",
1602
+ description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Call first with the hostname to get the DNS record, then again with verificationId to complete recovery (writes a new .sakupa/site.json and returns a download link for the current site content).",
1579
1603
  inputSchema: {
1580
1604
  hostname: z.string().describe('Hostname of the site to recover, e.g. "www.example.com".'),
1581
1605
  verificationId: z.string().optional().describe("Complete a recovery previously started for this hostname."),
@@ -1587,7 +1611,7 @@ Open this link in a browser to update the payment method, view invoices, or mana
1587
1611
  if (args.verificationId === void 0) {
1588
1612
  const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
1589
1613
  return text(
1590
- `Recovery started for ${args.hostname} (apex billing domain: ${res2.apexDomain}).
1614
+ `Recovery started for ${args.hostname} (apex domain: ${res2.apexDomain}).
1591
1615
 
1592
1616
  Create this DNS record to prove apex-domain control:
1593
1617
  name: ${res2.verificationRecord.name}
@@ -1606,13 +1630,13 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
1606
1630
  });
1607
1631
  writeSiteFile(ctx.projectDir, {
1608
1632
  siteId: res.siteId,
1609
- billingDomain: res.billingDomain,
1633
+ ...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
1610
1634
  credential: res.credential,
1611
1635
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1612
1636
  apiBaseUrl: ctx.apiBaseUrl
1613
1637
  });
1614
1638
  return text(
1615
- `Recovery complete for ${res.billingDomain}.
1639
+ `Recovery complete.
1616
1640
  Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
1617
1641
  Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
1618
1642
 
@@ -1629,39 +1653,32 @@ ${res.archiveUrl}`
1629
1653
  server.registerTool(
1630
1654
  "set_billing_plan",
1631
1655
  {
1632
- description: `Change the hosting plan of the apex billing domain or cancel/re-enable renewal. Plans: ${planCatalog()}. Plan changes go through the Stripe subscription; renewals bill the new plan. Auto-upgrade (one plan up when usage exceeds the current plan, capped at Business) is built in and not configurable. The API returns the billing consequences first; explicit owner confirmation (confirm: true) is required before anything is applied.`,
1656
+ description: `Change this site's hosting plan, cancel/re-enable renewal, or cancel immediately. Plans: ${planCatalog()}. Plan changes go through the Stripe subscription; renewals bill the new plan. Auto-upgrade (one plan up when usage exceeds the current plan, capped at Business) is built in and not configurable. cancelRenewal: true ends the subscription at the period end; cancelNow: true ends it IMMEDIATELY \u2014 the site reverts to a free 24h site at once and all paid data (custom domain bindings included) is removed, with no refund of the current period. The API returns the consequences first; explicit owner confirmation (confirm: true) is required before anything is applied.`,
1633
1657
  inputSchema: {
1634
- domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json."),
1635
1658
  plan: planEnum.optional().describe("Target monthly plan."),
1636
1659
  cancelRenewal: z.boolean().optional().describe(
1637
- "true: cancel renewal (hosting runs to the end of the paid month, then stops). false: re-enable renewal."
1660
+ "true: cancel renewal (the site stays permanent to the end of the paid month, then reverts to free). false: re-enable renewal."
1638
1661
  ),
1662
+ cancelNow: z.boolean().optional().describe("End the subscription immediately; the site reverts to free right away."),
1639
1663
  confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
1640
1664
  }
1641
1665
  },
1642
1666
  async (args) => {
1643
1667
  try {
1644
1668
  const site = requireSiteFile(ctx);
1645
- const domain = args.domain ?? site.billingDomain;
1646
- if (!domain) {
1647
- throw new SakupaError(
1648
- "invalid_request",
1649
- "No domain given and this project has no bound billing domain. Pass domain explicitly."
1650
- );
1651
- }
1652
1669
  const req = {
1653
- siteId: site.siteId,
1654
- ...args.plan !== void 0 ? { committedTier: args.plan } : {},
1670
+ ...args.plan !== void 0 ? { plan: args.plan } : {},
1655
1671
  ...args.cancelRenewal !== void 0 ? { cancelRenewal: args.cancelRenewal } : {},
1672
+ ...args.cancelNow !== void 0 ? { cancelNow: args.cancelNow } : {},
1656
1673
  confirm: args.confirm === true
1657
1674
  };
1658
1675
  try {
1659
- const res = await ctx.client.setBillingPlan(domain, site.credential, req);
1676
+ const res = await ctx.client.setBillingPlan(site.siteId, site.credential, req);
1660
1677
  return textJson(
1661
- `Billing plan updated for ${res.apexDomain}.
1678
+ `Billing updated for site ${res.siteId} (mode: ${res.mode}).
1662
1679
  Consequences:
1663
1680
  ${res.consequences.map((c) => `- ${c}`).join("\n")}
1664
- Applied plan:`,
1681
+ Result:`,
1665
1682
  res
1666
1683
  );
1667
1684
  } catch (e) {
@@ -1681,50 +1698,6 @@ ${e.message}
1681
1698
  }
1682
1699
  }
1683
1700
  );
1684
- server.registerTool(
1685
- "downgrade_to_free",
1686
- {
1687
- description: "Move this paid custom-domain site back to free temporary mode. Takes effect at the next billing period boundary by default; immediateStopServing stops custom-domain serving now but never erases the already-paid month. Remember to also cancel the domain subscription (set_billing_plan cancelRenewal: true or manage_billing) if no other site uses it. The API returns the consequences first; confirm: true is required to apply.",
1688
- inputSchema: {
1689
- immediateStopServing: z.boolean().optional().describe(
1690
- "Also stop custom-domain serving immediately (does not remove current-cycle charges)."
1691
- ),
1692
- confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
1693
- }
1694
- },
1695
- async (args) => {
1696
- try {
1697
- const site = requireSiteFile(ctx);
1698
- try {
1699
- const res = await ctx.client.downgradeSite(site.siteId, site.credential, {
1700
- ...args.immediateStopServing !== void 0 ? { immediateStopServing: args.immediateStopServing } : {},
1701
- confirm: args.confirm === true
1702
- });
1703
- return text(
1704
- `Downgrade scheduled for site ${res.siteId}.
1705
- Effective at: ${res.effectiveAt}
1706
- Immediate stop-serving applied: ${res.immediateStopApplied ? "yes" : "no"}
1707
- ${res.message}
1708
-
1709
- After it takes effect the site becomes a free temporary site again (24h validity, free banner, free limits). If no other site uses this domain, also cancel its hosting subscription (set_billing_plan cancelRenewal: true or manage_billing).`
1710
- );
1711
- } catch (e) {
1712
- if (isSakupaError(e) && e.code === "confirmation_required") {
1713
- return text(
1714
- `Confirmation required before downgrading \u2014 nothing was applied.
1715
-
1716
- ${e.message}
1717
- ` + (e.details !== void 0 ? `${JSON.stringify(e.details, null, 2)}
1718
- ` : "") + "\nPlease show these consequences to the user and, after their explicit confirmation, re-run downgrade_to_free with confirm: true."
1719
- );
1720
- }
1721
- throw e;
1722
- }
1723
- } catch (e) {
1724
- return toolError(e);
1725
- }
1726
- }
1727
- );
1728
1701
  server.registerTool(
1729
1702
  "create_support_ticket",
1730
1703
  {
@@ -1755,7 +1728,7 @@ ${e.message}
1755
1728
  server.registerTool(
1756
1729
  "report_bug",
1757
1730
  {
1758
- description: "Prepare and submit a sanitized bug report when a Sakupa tool failed and the issue looks like a product bug. Only whitelisted structured diagnostics are sent (tool name, error code/message, site id, billing domain, deployment id, timestamps, client/MCP version, request id) \u2014 NEVER file contents, source code, secrets, .env values or credentials. Without confirmSubmit: true the exact payload is shown for user review and nothing is submitted.",
1731
+ description: "Prepare and submit a sanitized bug report when a Sakupa tool failed and the issue looks like a product bug. Only whitelisted structured diagnostics are sent (tool name, error code/message, site id, bound domain, deployment id, timestamps, client/MCP version, request id) \u2014 NEVER file contents, source code, secrets, .env values or credentials. Without confirmSubmit: true the exact payload is shown for user review and nothing is submitted.",
1759
1732
  inputSchema: {
1760
1733
  toolName: z.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
1761
1734
  errorCode: z.string().optional(),
@@ -1775,7 +1748,7 @@ ${e.message}
1775
1748
  ...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
1776
1749
  ...args.errorMessage !== void 0 ? { sanitizedErrorMessage: args.errorMessage } : {},
1777
1750
  ...site ? { siteId: site.siteId } : {},
1778
- ...site?.billingDomain ? { billingDomain: site.billingDomain } : {},
1751
+ ...site?.boundDomain ? { boundDomain: site.boundDomain } : {},
1779
1752
  ...args.deploymentId !== void 0 ? { deploymentId: args.deploymentId } : {},
1780
1753
  occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
1781
1754
  clientType: CLIENT_TYPE,
@@ -1818,19 +1791,23 @@ Workflow:
1818
1791
  site (public URL {shortId}.sakupa.com, valid 24 hours, free banner shown) and stores the
1819
1792
  management credential in .sakupa/site.json. Deploying again updates the site and refreshes
1820
1793
  its validity; refresh_site extends validity without uploading.
1821
- 3. For long-term use, subscribe the apex domain to a monthly hosting plan (subscribe_domain ->
1794
+ 3. To make the site PERMANENT, subscribe it to a monthly hosting plan (subscribe_site ->
1822
1795
  Stripe-hosted checkout; water/personal/share/business, auto-upgrade when the site outgrows
1823
- its plan), then bind the custom domain (bind_domain): the billing subject is always the
1824
- registered apex domain and ownership is proven only by DNS control. billing_status,
1825
- set_billing_plan, manage_billing, downgrade_to_free and recover_domain_site manage the
1826
- paid lifecycle.
1796
+ its plan). Paying makes the {shortId}.sakupa.com URL permanent \u2014 that is what payment buys.
1797
+ 4. Optionally bind a custom domain to the subscribed site (bind_domain): an included extra
1798
+ serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
1799
+ first verified request wins; unverified requests expire after 72 hours. billing_status,
1800
+ set_billing_plan, manage_billing and recover_domain_site manage the paid lifecycle.
1801
+ Canceling the subscription immediately reverts the site to a free 24h site and removes all
1802
+ paid data.
1827
1803
 
1828
1804
  Safety boundaries:
1829
1805
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
1830
1806
  - Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
1831
1807
  - Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
1832
- - A subscription is payment state only; it never grants domain ownership or management rights.
1833
- - The management credential lives only in .sakupa/site.json; never share or upload it.`;
1808
+ - A subscription never grants domain ownership; only DNS verification does.
1809
+ - The management credential lives only in .sakupa/site.json; never share or upload it. Without
1810
+ a bound custom domain, a lost credential is unrecoverable by design.`;
1834
1811
  function createSakupaMcpServer(opts) {
1835
1812
  const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
1836
1813
  const server = new McpServer(