@sakupa/mcp 0.3.0 → 0.5.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 +85 -26
  2. package/dist/index.js +88 -29
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -126,6 +126,9 @@ var FORBIDDEN_PATH_SEGMENTS = [
126
126
  ];
127
127
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
128
128
 
129
+ // ../core/dist/domain/version.js
130
+ var SAKUPA_MCP_VERSION = "0.5.0";
131
+
129
132
  // ../core/dist/domain/errors.js
130
133
  var HTTP_STATUS = {
131
134
  invalid_request: 400,
@@ -461,6 +464,12 @@ var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
461
464
  var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
462
465
  var utf8Encoder = new TextEncoder();
463
466
 
467
+ // ../core/dist/services/subscriptions.js
468
+ var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
469
+
470
+ // ../core/dist/services/billing-cancellation.js
471
+ var encoder = new TextEncoder();
472
+
464
473
  // src/server.ts
465
474
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
466
475
 
@@ -609,6 +618,13 @@ var HttpApiClient = class {
609
618
  { credential, body: req }
610
619
  );
611
620
  }
621
+ async requestBillingCancellation(req) {
622
+ return this.call(
623
+ "POST",
624
+ "/v1/billing/cancellation-requests",
625
+ { body: req }
626
+ );
627
+ }
612
628
  async createTicket(credential, req) {
613
629
  return this.call("POST", "/v1/support/tickets", {
614
630
  credential,
@@ -1057,7 +1073,7 @@ async function analyzeProject(projectDir, opts = {}) {
1057
1073
 
1058
1074
  // src/project-file.ts
1059
1075
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1060
- import { join as join2 } from "node:path";
1076
+ import { dirname, join as join2 } from "node:path";
1061
1077
  var SITE_DIR = ".sakupa";
1062
1078
  var SITE_FILE = "site.json";
1063
1079
  function siteFilePath(projectDir) {
@@ -1100,9 +1116,22 @@ function writeSiteFile(projectDir, file) {
1100
1116
  } catch {
1101
1117
  }
1102
1118
  }
1119
+ function isInsideGitRepo(projectDir) {
1120
+ let dir = projectDir;
1121
+ for (; ; ) {
1122
+ if (existsSync(join2(dir, ".git"))) return true;
1123
+ const parent = dirname(dir);
1124
+ if (parent === dir) return false;
1125
+ dir = parent;
1126
+ }
1127
+ }
1128
+ function credentialGitReminder(projectDir) {
1129
+ if (!isInsideGitRepo(projectDir)) return "";
1130
+ return '\nNOTE: this project is inside a git repository. The management credential in .sakupa/site.json is the key to this site \u2014 do NOT commit it to a PUBLIC repository (add ".sakupa/" to .gitignore yourself if you want to keep it out of version control).';
1131
+ }
1103
1132
 
1104
1133
  // src/version.ts
1105
- var MCP_VERSION = "0.3.0";
1134
+ var MCP_VERSION = SAKUPA_MCP_VERSION;
1106
1135
  var CLIENT_TYPE = "sakupa-mcp";
1107
1136
 
1108
1137
  // src/tools/context.ts
@@ -1270,6 +1299,9 @@ Next action: ${analysis.suggestedNextAction}`,
1270
1299
  outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1271
1300
  spaFallback: z.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
1272
1301
  spaFallbackConfirmed: z.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
1302
+ publicConfirmed: z.boolean().optional().describe(
1303
+ "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1304
+ ),
1273
1305
  lang: z.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1274
1306
  }
1275
1307
  },
@@ -1290,6 +1322,11 @@ Next action: ${analysis.suggestedNextAction}`,
1290
1322
  const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1291
1323
  const manifest = await buildHashedManifest(files, outputAbs);
1292
1324
  const existing = readSiteFile(ctx.projectDir);
1325
+ if (!existing && args.publicConfirmed !== true) {
1326
+ return text(
1327
+ `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`
1328
+ );
1329
+ }
1293
1330
  ensureUploadSizeWithinLimits(manifest, !existing);
1294
1331
  if (!existing) {
1295
1332
  const created = await ctx.client.createSite({
@@ -1318,7 +1355,7 @@ Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1318
1355
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
1319
1356
  ` : "") + `
1320
1357
  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.
1321
- ` + (finalized2.warnings.length > 0 ? `
1358
+ ` + credentialGitReminder(ctx.projectDir) + (finalized2.warnings.length > 0 ? `
1322
1359
  Warnings:
1323
1360
  ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
1324
1361
  );
@@ -1449,9 +1486,11 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1449
1486
  server.registerTool(
1450
1487
  "bind_domain",
1451
1488
  {
1452
- 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.",
1489
+ 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. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership. A binding request never reserves the domain: whoever proves DNS control first gets it, and unverified requests expire after 72 hours. Call again with verificationId to check progress.",
1453
1490
  inputSchema: {
1454
- hostname: z.string().describe('Hostname to bind, e.g. "example.com" or "www.example.com".'),
1491
+ hostname: z.string().describe(
1492
+ 'Domain to bind: the apex ("example.com") or its www form ("www.example.com") \u2014 both mean the same unit. Other subdomains cannot be bound.'
1493
+ ),
1455
1494
  verificationId: z.string().optional().describe("Check an existing DNS verification instead of starting a new one.")
1456
1495
  }
1457
1496
  },
@@ -1466,7 +1505,7 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1466
1505
  return text(
1467
1506
  `DNS verification ${res2.verificationId}: ${res2.status}
1468
1507
  ${res2.message}
1469
- ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificate and serving setup are in progress; check again with bind_domain + verificationId later.
1508
+ ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
1470
1509
  ` : "") + (res2.pendingDnsRecords.length > 0 ? `
1471
1510
  DNS records still required:
1472
1511
  ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
@@ -1477,18 +1516,16 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
1477
1516
  hostname: args.hostname
1478
1517
  };
1479
1518
  const res = await ctx.client.bindDomain(site.credential, req);
1480
- 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.`;
1481
1519
  return text(
1482
- `Domain binding started for ${res.hostname}.
1520
+ `Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
1483
1521
 
1484
1522
  1. Prove control of ${res.apexDomain} by creating this DNS record:
1485
1523
  name: ${res.verificationRecord.name}
1486
1524
  type: ${res.verificationRecord.type}
1487
1525
  value: ${res.verificationRecord.value}
1488
- 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.
1526
+ Ownership comes ONLY from DNS control; paying never grants it. This request does not reserve the domain \u2014 the first verified request wins, and this challenge expires after 72 hours.
1489
1527
 
1490
- 2. Serving DNS (after verification): ${servingHint}
1491
- Server guidance: ${res.servingInstructions}
1528
+ 2. Serving DNS (after verification): ${res.servingInstructions}
1492
1529
 
1493
1530
  Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`
1494
1531
  );
@@ -1530,18 +1567,40 @@ Full status:`, res);
1530
1567
  server.registerTool(
1531
1568
  "manage_billing",
1532
1569
  {
1533
- 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).",
1534
- inputSchema: {}
1570
+ 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. With .sakupa/site.json, this opens the site-specific portal. If the local credential was lost, pass the remembered Sakupa site URL: Sakupa returns the same generic response for every URL and, only when an eligible subscription exists, emails its Stripe billing address a one-time period-end cancellation confirmation link. This never restores site authority.",
1571
+ inputSchema: {
1572
+ siteUrl: z.string().optional().describe(
1573
+ "Without .sakupa/site.json only: the remembered https://{shortId}.sakupa.com URL."
1574
+ )
1575
+ }
1535
1576
  },
1536
- async () => {
1577
+ async (args) => {
1537
1578
  try {
1538
- const site = requireSiteFile(ctx);
1539
- const res = await ctx.client.createBillingPortal(site.siteId, site.credential);
1540
- return text(
1541
- `Stripe billing portal for this site:
1542
- ${res.portalUrl}
1579
+ const site = readSiteFile(ctx.projectDir);
1580
+ if (site) {
1581
+ if (args.siteUrl !== void 0) {
1582
+ return text(
1583
+ "A local Sakupa credential is present, so manage_billing uses the site-specific Stripe portal. Remove siteUrl and call manage_billing again; the public lost-key path is deliberately unavailable while site authority is present."
1584
+ );
1585
+ }
1586
+ const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
1587
+ return text(
1588
+ `Stripe billing portal for this site:
1589
+ ${res2.portalUrl}
1543
1590
 
1544
1591
  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.`
1592
+ );
1593
+ }
1594
+ if (args.siteUrl === void 0) {
1595
+ return text(
1596
+ "No .sakupa/site.json was found. Provide the remembered Sakupa site URL to request a cancellation email. Sakupa will not reveal whether the URL, site, billing email, or subscription exists. If eligible, the original Stripe billing email receives a short-lived one-time link. Opening it only shows the consequences; the user must press the confirmation button to stop the next renewal. This does not recover a key or grant deployment, download, or content access."
1597
+ );
1598
+ }
1599
+ const res = await ctx.client.requestBillingCancellation({ siteUrl: args.siteUrl });
1600
+ return text(
1601
+ `${res.message}
1602
+
1603
+ For privacy, this response is identical whether or not the URL, site, customer, email, or subscription exists. Check the original Stripe billing inbox. The email link expires quickly and can only schedule cancellation at the current paid period end; it cannot restore the Sakupa credential or access the site.`
1545
1604
  );
1546
1605
  } catch (e) {
1547
1606
  return toolError(e);
@@ -1593,7 +1652,7 @@ Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
1593
1652
  Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
1594
1653
 
1595
1654
  A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
1596
-
1655
+ ` + credentialGitReminder(ctx.projectDir) + `
1597
1656
  Download the current site content (signed URL):
1598
1657
  ${res.archiveUrl}`
1599
1658
  );
@@ -1605,13 +1664,12 @@ ${res.archiveUrl}`
1605
1664
  server.registerTool(
1606
1665
  "set_billing_plan",
1607
1666
  {
1608
- 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.`,
1667
+ description: `Change this site's hosting plan 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. cancelRenewal: true ends the subscription at the period end; the paid service remains available until then. The site reverts to a free 24h site only after the signed Stripe final-cancellation webhook arrives. The API returns the consequences first; explicit owner confirmation (confirm: true) is required before anything is applied.`,
1609
1668
  inputSchema: {
1610
1669
  plan: planEnum.optional().describe("Target monthly plan."),
1611
1670
  cancelRenewal: z.boolean().optional().describe(
1612
1671
  "true: cancel renewal (the site stays permanent to the end of the paid month, then reverts to free). false: re-enable renewal."
1613
1672
  ),
1614
- cancelNow: z.boolean().optional().describe("End the subscription immediately; the site reverts to free right away."),
1615
1673
  confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
1616
1674
  }
1617
1675
  },
@@ -1621,7 +1679,6 @@ ${res.archiveUrl}`
1621
1679
  const req = {
1622
1680
  ...args.plan !== void 0 ? { plan: args.plan } : {},
1623
1681
  ...args.cancelRenewal !== void 0 ? { cancelRenewal: args.cancelRenewal } : {},
1624
- ...args.cancelNow !== void 0 ? { cancelNow: args.cancelNow } : {},
1625
1682
  confirm: args.confirm === true
1626
1683
  };
1627
1684
  try {
@@ -1802,8 +1859,8 @@ Workflow:
1802
1859
  serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
1803
1860
  first verified request wins; unverified requests expire after 72 hours. billing_status,
1804
1861
  set_billing_plan, manage_billing and recover_domain_site manage the paid lifecycle.
1805
- Canceling the subscription immediately reverts the site to a free 24h site and removes all
1806
- paid data.
1862
+ An immediate Stripe cancellation reverts the site to a free 24h site and removes all paid
1863
+ data when Sakupa receives the signed cancellation webhook.
1807
1864
 
1808
1865
  Safety boundaries:
1809
1866
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
@@ -1811,7 +1868,9 @@ Safety boundaries:
1811
1868
  - Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
1812
1869
  - A subscription never grants domain ownership; only DNS verification does.
1813
1870
  - The management credential lives only in .sakupa/site.json; never share or upload it. Without
1814
- a bound custom domain, a lost credential is unrecoverable by design.`;
1871
+ a bound custom domain, a lost credential is unrecoverable by design. manage_billing can accept
1872
+ the remembered Sakupa URL and request a one-time cancellation link sent only to the exact
1873
+ subscription's Stripe billing email; it never restores site authority.`;
1815
1874
  function createSakupaMcpServer(opts) {
1816
1875
  const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
1817
1876
  const server = new McpServer(
package/dist/index.js CHANGED
@@ -1,7 +1,3 @@
1
- // src/version.ts
2
- var MCP_VERSION = "0.3.0";
3
- var CLIENT_TYPE = "sakupa-mcp";
4
-
5
1
  // ../core/dist/domain/constants.js
6
2
  var SERVICE_DOMAIN = "sakupa.com";
7
3
  var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
@@ -124,6 +120,9 @@ var FORBIDDEN_PATH_SEGMENTS = [
124
120
  ];
125
121
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
126
122
 
123
+ // ../core/dist/domain/version.js
124
+ var SAKUPA_MCP_VERSION = "0.5.0";
125
+
127
126
  // ../core/dist/domain/errors.js
128
127
  var HTTP_STATUS = {
129
128
  invalid_request: 400,
@@ -459,6 +458,16 @@ var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
459
458
  var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
460
459
  var utf8Encoder = new TextEncoder();
461
460
 
461
+ // ../core/dist/services/subscriptions.js
462
+ var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
463
+
464
+ // ../core/dist/services/billing-cancellation.js
465
+ var encoder = new TextEncoder();
466
+
467
+ // src/version.ts
468
+ var MCP_VERSION = SAKUPA_MCP_VERSION;
469
+ var CLIENT_TYPE = "sakupa-mcp";
470
+
462
471
  // src/transport.ts
463
472
  var FetchTransport = class {
464
473
  baseUrl;
@@ -657,6 +666,13 @@ var HttpApiClient = class {
657
666
  { credential, body: req }
658
667
  );
659
668
  }
669
+ async requestBillingCancellation(req) {
670
+ return this.call(
671
+ "POST",
672
+ "/v1/billing/cancellation-requests",
673
+ { body: req }
674
+ );
675
+ }
660
676
  async createTicket(credential, req) {
661
677
  return this.call("POST", "/v1/support/tickets", {
662
678
  credential,
@@ -673,7 +689,7 @@ var HttpApiClient = class {
673
689
 
674
690
  // src/project-file.ts
675
691
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
676
- import { join } from "node:path";
692
+ import { dirname, join } from "node:path";
677
693
  var SITE_DIR = ".sakupa";
678
694
  var SITE_FILE = "site.json";
679
695
  function siteFilePath(projectDir) {
@@ -722,6 +738,19 @@ function deleteSiteFile(projectDir) {
722
738
  rmSync(path, { force: true });
723
739
  }
724
740
  }
741
+ function isInsideGitRepo(projectDir) {
742
+ let dir = projectDir;
743
+ for (; ; ) {
744
+ if (existsSync(join(dir, ".git"))) return true;
745
+ const parent = dirname(dir);
746
+ if (parent === dir) return false;
747
+ dir = parent;
748
+ }
749
+ }
750
+ function credentialGitReminder(projectDir) {
751
+ if (!isInsideGitRepo(projectDir)) return "";
752
+ return '\nNOTE: this project is inside a git repository. The management credential in .sakupa/site.json is the key to this site \u2014 do NOT commit it to a PUBLIC repository (add ".sakupa/" to .gitignore yourself if you want to keep it out of version control).';
753
+ }
725
754
 
726
755
  // src/analyze/analyzer.ts
727
756
  import { promises as fs } from "node:fs";
@@ -1318,6 +1347,9 @@ Next action: ${analysis.suggestedNextAction}`,
1318
1347
  outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1319
1348
  spaFallback: z.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
1320
1349
  spaFallbackConfirmed: z.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
1350
+ publicConfirmed: z.boolean().optional().describe(
1351
+ "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1352
+ ),
1321
1353
  lang: z.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1322
1354
  }
1323
1355
  },
@@ -1338,6 +1370,11 @@ Next action: ${analysis.suggestedNextAction}`,
1338
1370
  const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1339
1371
  const manifest = await buildHashedManifest(files, outputAbs);
1340
1372
  const existing = readSiteFile(ctx.projectDir);
1373
+ if (!existing && args.publicConfirmed !== true) {
1374
+ return text(
1375
+ `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`
1376
+ );
1377
+ }
1341
1378
  ensureUploadSizeWithinLimits(manifest, !existing);
1342
1379
  if (!existing) {
1343
1380
  const created = await ctx.client.createSite({
@@ -1366,7 +1403,7 @@ Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1366
1403
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
1367
1404
  ` : "") + `
1368
1405
  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.
1369
- ` + (finalized2.warnings.length > 0 ? `
1406
+ ` + credentialGitReminder(ctx.projectDir) + (finalized2.warnings.length > 0 ? `
1370
1407
  Warnings:
1371
1408
  ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
1372
1409
  );
@@ -1497,9 +1534,11 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1497
1534
  server.registerTool(
1498
1535
  "bind_domain",
1499
1536
  {
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.",
1537
+ 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. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership. A binding request never reserves the domain: whoever proves DNS control first gets it, and unverified requests expire after 72 hours. Call again with verificationId to check progress.",
1501
1538
  inputSchema: {
1502
- hostname: z.string().describe('Hostname to bind, e.g. "example.com" or "www.example.com".'),
1539
+ hostname: z.string().describe(
1540
+ 'Domain to bind: the apex ("example.com") or its www form ("www.example.com") \u2014 both mean the same unit. Other subdomains cannot be bound.'
1541
+ ),
1503
1542
  verificationId: z.string().optional().describe("Check an existing DNS verification instead of starting a new one.")
1504
1543
  }
1505
1544
  },
@@ -1514,7 +1553,7 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1514
1553
  return text(
1515
1554
  `DNS verification ${res2.verificationId}: ${res2.status}
1516
1555
  ${res2.message}
1517
- ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificate and serving setup are in progress; check again with bind_domain + verificationId later.
1556
+ ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
1518
1557
  ` : "") + (res2.pendingDnsRecords.length > 0 ? `
1519
1558
  DNS records still required:
1520
1559
  ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
@@ -1525,18 +1564,16 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
1525
1564
  hostname: args.hostname
1526
1565
  };
1527
1566
  const res = await ctx.client.bindDomain(site.credential, req);
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.`;
1529
1567
  return text(
1530
- `Domain binding started for ${res.hostname}.
1568
+ `Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
1531
1569
 
1532
1570
  1. Prove control of ${res.apexDomain} by creating this DNS record:
1533
1571
  name: ${res.verificationRecord.name}
1534
1572
  type: ${res.verificationRecord.type}
1535
1573
  value: ${res.verificationRecord.value}
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.
1574
+ Ownership comes ONLY from DNS control; paying never grants it. This request does not reserve the domain \u2014 the first verified request wins, and this challenge expires after 72 hours.
1537
1575
 
1538
- 2. Serving DNS (after verification): ${servingHint}
1539
- Server guidance: ${res.servingInstructions}
1576
+ 2. Serving DNS (after verification): ${res.servingInstructions}
1540
1577
 
1541
1578
  Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`
1542
1579
  );
@@ -1578,18 +1615,40 @@ Full status:`, res);
1578
1615
  server.registerTool(
1579
1616
  "manage_billing",
1580
1617
  {
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: {}
1618
+ 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. With .sakupa/site.json, this opens the site-specific portal. If the local credential was lost, pass the remembered Sakupa site URL: Sakupa returns the same generic response for every URL and, only when an eligible subscription exists, emails its Stripe billing address a one-time period-end cancellation confirmation link. This never restores site authority.",
1619
+ inputSchema: {
1620
+ siteUrl: z.string().optional().describe(
1621
+ "Without .sakupa/site.json only: the remembered https://{shortId}.sakupa.com URL."
1622
+ )
1623
+ }
1583
1624
  },
1584
- async () => {
1625
+ async (args) => {
1585
1626
  try {
1586
- const site = requireSiteFile(ctx);
1587
- const res = await ctx.client.createBillingPortal(site.siteId, site.credential);
1588
- return text(
1589
- `Stripe billing portal for this site:
1590
- ${res.portalUrl}
1627
+ const site = readSiteFile(ctx.projectDir);
1628
+ if (site) {
1629
+ if (args.siteUrl !== void 0) {
1630
+ return text(
1631
+ "A local Sakupa credential is present, so manage_billing uses the site-specific Stripe portal. Remove siteUrl and call manage_billing again; the public lost-key path is deliberately unavailable while site authority is present."
1632
+ );
1633
+ }
1634
+ const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
1635
+ return text(
1636
+ `Stripe billing portal for this site:
1637
+ ${res2.portalUrl}
1591
1638
 
1592
1639
  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.`
1640
+ );
1641
+ }
1642
+ if (args.siteUrl === void 0) {
1643
+ return text(
1644
+ "No .sakupa/site.json was found. Provide the remembered Sakupa site URL to request a cancellation email. Sakupa will not reveal whether the URL, site, billing email, or subscription exists. If eligible, the original Stripe billing email receives a short-lived one-time link. Opening it only shows the consequences; the user must press the confirmation button to stop the next renewal. This does not recover a key or grant deployment, download, or content access."
1645
+ );
1646
+ }
1647
+ const res = await ctx.client.requestBillingCancellation({ siteUrl: args.siteUrl });
1648
+ return text(
1649
+ `${res.message}
1650
+
1651
+ For privacy, this response is identical whether or not the URL, site, customer, email, or subscription exists. Check the original Stripe billing inbox. The email link expires quickly and can only schedule cancellation at the current paid period end; it cannot restore the Sakupa credential or access the site.`
1593
1652
  );
1594
1653
  } catch (e) {
1595
1654
  return toolError(e);
@@ -1641,7 +1700,7 @@ Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
1641
1700
  Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
1642
1701
 
1643
1702
  A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
1644
-
1703
+ ` + credentialGitReminder(ctx.projectDir) + `
1645
1704
  Download the current site content (signed URL):
1646
1705
  ${res.archiveUrl}`
1647
1706
  );
@@ -1653,13 +1712,12 @@ ${res.archiveUrl}`
1653
1712
  server.registerTool(
1654
1713
  "set_billing_plan",
1655
1714
  {
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.`,
1715
+ description: `Change this site's hosting plan 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. cancelRenewal: true ends the subscription at the period end; the paid service remains available until then. The site reverts to a free 24h site only after the signed Stripe final-cancellation webhook arrives. The API returns the consequences first; explicit owner confirmation (confirm: true) is required before anything is applied.`,
1657
1716
  inputSchema: {
1658
1717
  plan: planEnum.optional().describe("Target monthly plan."),
1659
1718
  cancelRenewal: z.boolean().optional().describe(
1660
1719
  "true: cancel renewal (the site stays permanent to the end of the paid month, then reverts to free). false: re-enable renewal."
1661
1720
  ),
1662
- cancelNow: z.boolean().optional().describe("End the subscription immediately; the site reverts to free right away."),
1663
1721
  confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
1664
1722
  }
1665
1723
  },
@@ -1669,7 +1727,6 @@ ${res.archiveUrl}`
1669
1727
  const req = {
1670
1728
  ...args.plan !== void 0 ? { plan: args.plan } : {},
1671
1729
  ...args.cancelRenewal !== void 0 ? { cancelRenewal: args.cancelRenewal } : {},
1672
- ...args.cancelNow !== void 0 ? { cancelNow: args.cancelNow } : {},
1673
1730
  confirm: args.confirm === true
1674
1731
  };
1675
1732
  try {
@@ -1798,8 +1855,8 @@ Workflow:
1798
1855
  serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
1799
1856
  first verified request wins; unverified requests expire after 72 hours. billing_status,
1800
1857
  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.
1858
+ An immediate Stripe cancellation reverts the site to a free 24h site and removes all paid
1859
+ data when Sakupa receives the signed cancellation webhook.
1803
1860
 
1804
1861
  Safety boundaries:
1805
1862
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
@@ -1807,7 +1864,9 @@ Safety boundaries:
1807
1864
  - Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
1808
1865
  - A subscription never grants domain ownership; only DNS verification does.
1809
1866
  - 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.`;
1867
+ a bound custom domain, a lost credential is unrecoverable by design. manage_billing can accept
1868
+ the remembered Sakupa URL and request a one-time cancellation link sent only to the exact
1869
+ subscription's Stripe billing email; it never restores site authority.`;
1811
1870
  function createSakupaMcpServer(opts) {
1812
1871
  const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
1813
1872
  const server = new McpServer(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakupa/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Sakupa MCP server: publish AI-made static sites from your AI tool. AI-made pages, live in seconds.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",