@sakupa/mcp 0.2.0 → 0.4.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 +134 -207
  2. package/dist/index.js +186 -259
  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.4.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 {
@@ -500,16 +448,70 @@ async function sha256Hex(bytes) {
500
448
  }
501
449
 
502
450
  // ../core/dist/domain/subscription.js
503
- 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.";
504
452
 
505
453
  // ../core/dist/dto.js
506
454
  var CREDENTIAL_HEADER = "x-sakupa-credential";
507
455
  var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
456
+ var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
508
457
 
509
458
  // ../core/dist/services/sites.js
510
459
  var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
511
460
  var utf8Encoder = new TextEncoder();
512
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
+
513
515
  // src/api-client.ts
514
516
  var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
515
517
  "invalid_request",
@@ -520,11 +522,11 @@ var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
520
522
  "conflict",
521
523
  "state_conflict",
522
524
  "rate_limited",
523
- "insufficient_balance",
524
525
  "payment_required",
525
526
  "confirmation_required",
526
527
  "dns_not_verified",
527
528
  "not_deployable",
529
+ "upgrade_required",
528
530
  "internal"
529
531
  ]);
530
532
  var HttpApiClient = class {
@@ -625,14 +627,7 @@ var HttpApiClient = class {
625
627
  { body: req }
626
628
  );
627
629
  }
628
- /** Wallet-era recharge checkout; unused (kept for SakupaApiClient compatibility). */
629
- async createCheckout(req) {
630
- return this.call("POST", "/v1/payments/checkout", {
631
- body: req,
632
- idempotencyKey: req.idempotencyKey
633
- });
634
- }
635
- /** Subscription checkout: a fixed monthly plan for one apex domain (owner-only). */
630
+ /** Subscription checkout: a fixed monthly plan permanentizing one site (owner-only). */
636
631
  async createPlanCheckout(req, credential) {
637
632
  return this.call("POST", "/v1/payments/checkout", {
638
633
  credential,
@@ -641,31 +636,24 @@ var HttpApiClient = class {
641
636
  });
642
637
  }
643
638
  /** Stripe-hosted billing portal (payment method, invoices, cancellation). */
644
- async createBillingPortal(apexDomain, credential) {
639
+ async createBillingPortal(siteId, credential) {
645
640
  return this.call(
646
641
  "POST",
647
- `/v1/billing/${encodeURIComponent(apexDomain)}/portal`,
642
+ `/v1/sites/${encodeURIComponent(siteId)}/billing/portal`,
648
643
  { credential, body: {} }
649
644
  );
650
645
  }
651
- async getBillingStatus(apexDomain, credential) {
646
+ async getBillingStatus(siteId, credential) {
652
647
  return this.call(
653
648
  "GET",
654
- `/v1/billing/${encodeURIComponent(apexDomain)}`,
655
- credential !== void 0 ? { credential } : {}
656
- );
657
- }
658
- async setBillingPlan(apexDomain, credential, req) {
659
- return this.call(
660
- "POST",
661
- `/v1/billing/${encodeURIComponent(apexDomain)}/plan`,
662
- { credential, body: req }
649
+ `/v1/sites/${encodeURIComponent(siteId)}/billing`,
650
+ { credential }
663
651
  );
664
652
  }
665
- async downgradeSite(siteId, credential, req) {
653
+ async setBillingPlan(siteId, credential, req) {
666
654
  return this.call(
667
655
  "POST",
668
- `/v1/sites/${encodeURIComponent(siteId)}/downgrade`,
656
+ `/v1/sites/${encodeURIComponent(siteId)}/billing/plan`,
669
657
  { credential, body: req }
670
658
  );
671
659
  }
@@ -685,7 +673,7 @@ var HttpApiClient = class {
685
673
 
686
674
  // src/project-file.ts
687
675
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
688
- import { join } from "node:path";
676
+ import { dirname, join } from "node:path";
689
677
  var SITE_DIR = ".sakupa";
690
678
  var SITE_FILE = "site.json";
691
679
  function siteFilePath(projectDir) {
@@ -711,8 +699,7 @@ function readSiteFile(projectDir) {
711
699
  apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : "",
712
700
  ...typeof parsed.shortId === "string" ? { shortId: parsed.shortId } : {},
713
701
  ...typeof parsed.url === "string" ? { url: parsed.url } : {},
714
- ...typeof parsed.billingDomain === "string" ? { billingDomain: parsed.billingDomain } : {},
715
- ...typeof parsed.billingDomainId === "string" ? { billingDomainId: parsed.billingDomainId } : {}
702
+ ...typeof parsed.boundDomain === "string" ? { boundDomain: parsed.boundDomain } : {}
716
703
  };
717
704
  } catch {
718
705
  return null;
@@ -735,6 +722,19 @@ function deleteSiteFile(projectDir) {
735
722
  rmSync(path, { force: true });
736
723
  }
737
724
  }
725
+ function isInsideGitRepo(projectDir) {
726
+ let dir = projectDir;
727
+ for (; ; ) {
728
+ if (existsSync(join(dir, ".git"))) return true;
729
+ const parent = dirname(dir);
730
+ if (parent === dir) return false;
731
+ dir = parent;
732
+ }
733
+ }
734
+ function credentialGitReminder(projectDir) {
735
+ if (!isInsideGitRepo(projectDir)) return "";
736
+ 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).';
737
+ }
738
738
 
739
739
  // src/analyze/analyzer.ts
740
740
  import { promises as fs } from "node:fs";
@@ -1203,8 +1203,8 @@ var severityEnum = z.enum(["low", "medium", "high", "critical"]);
1203
1203
  function planCatalog() {
1204
1204
  return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
1205
1205
  }
1206
- function subscriptionWarning(domain, plan) {
1207
- return SUBSCRIPTION_WARNING_TEXT.replaceAll("{billingDomain}", domain).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
1206
+ function subscriptionWarning(siteUrl, plan) {
1207
+ return SUBSCRIPTION_WARNING_TEXT.replaceAll("{siteUrl}", siteUrl).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
1208
1208
  }
1209
1209
  var ticketCategoryEnum = z.enum([
1210
1210
  "billing",
@@ -1257,7 +1257,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
1257
1257
  if (total > FREE_SITE_MAX_TOTAL_BYTES) {
1258
1258
  throw new SakupaError(
1259
1259
  "validation_failed",
1260
- `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 use a paid domain plan for larger sites.`
1260
+ `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.`
1261
1261
  );
1262
1262
  }
1263
1263
  }
@@ -1326,7 +1326,7 @@ Next action: ${analysis.suggestedNextAction}`,
1326
1326
  server.registerTool(
1327
1327
  "deploy_site",
1328
1328
  {
1329
- 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.`,
1329
+ 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.`,
1330
1330
  inputSchema: {
1331
1331
  outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1332
1332
  spaFallback: z.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
@@ -1378,8 +1378,8 @@ Next action: ${analysis.suggestedNextAction}`,
1378
1378
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1379
1379
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
1380
1380
  ` : "") + `
1381
- 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.
1382
- ` + (finalized2.warnings.length > 0 ? `
1381
+ 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.
1382
+ ` + credentialGitReminder(ctx.projectDir) + (finalized2.warnings.length > 0 ? `
1383
1383
  Warnings:
1384
1384
  ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
1385
1385
  );
@@ -1419,8 +1419,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
1419
1419
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1420
1420
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
1421
1421
  ` : "") + (finalized.mode === "free" ? `
1422
- Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh_site call.
1423
- ` : "") + (finalized.warnings.length > 0 ? `
1422
+ 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.
1423
+ ` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
1424
1424
  Warnings:
1425
1425
  ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1426
1426
  );
@@ -1432,7 +1432,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1432
1432
  server.registerTool(
1433
1433
  "refresh_site",
1434
1434
  {
1435
- description: "Refresh the validity of the free temporary site WITHOUT uploading content. Uses the local credential in .sakupa/site.json.",
1435
+ 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.",
1436
1436
  inputSchema: {}
1437
1437
  },
1438
1438
  async () => {
@@ -1451,7 +1451,7 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1451
1451
  server.registerTool(
1452
1452
  "site_status",
1453
1453
  {
1454
- description: "Show the current status of this project's Sakupa site: URL, mode (free/paid), expiry, domain binding, size, last deployment and warnings.",
1454
+ 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.",
1455
1455
  inputSchema: {}
1456
1456
  },
1457
1457
  async () => {
@@ -1464,12 +1464,57 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1464
1464
  }
1465
1465
  }
1466
1466
  );
1467
+ server.registerTool(
1468
+ "subscribe_site",
1469
+ {
1470
+ 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.`,
1471
+ inputSchema: {
1472
+ plan: planEnum.describe(
1473
+ "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
1474
+ ),
1475
+ confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
1476
+ }
1477
+ },
1478
+ async (args) => {
1479
+ try {
1480
+ const site = requireSiteFile(ctx);
1481
+ const siteUrl = site.url ?? (site.shortId ? `https://${site.shortId}.sakupa.com` : site.siteId);
1482
+ if (args.confirm !== true) {
1483
+ return text(
1484
+ `${subscriptionWarning(siteUrl, args.plan)}
1485
+
1486
+ 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.`
1487
+ );
1488
+ }
1489
+ const res = await ctx.client.createPlanCheckout(
1490
+ {
1491
+ siteId: site.siteId,
1492
+ plan: args.plan,
1493
+ idempotencyKey: randomUUID(),
1494
+ confirmPlan: true
1495
+ },
1496
+ site.credential
1497
+ );
1498
+ return text(
1499
+ `Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
1500
+ ${res.checkoutUrl}
1501
+
1502
+ 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.
1503
+ Once payment confirms, the site becomes permanent on its current URL. Binding a custom domain (bind_domain) is optional and still requires DNS verification.`
1504
+ );
1505
+ } catch (e) {
1506
+ return toolError(e);
1507
+ }
1508
+ }
1509
+ );
1467
1510
  server.registerTool(
1468
1511
  "bind_domain",
1469
1512
  {
1470
- 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.`,
1513
+ 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.",
1471
1514
  inputSchema: {
1472
- hostname: z.string().describe('Hostname to bind, e.g. "example.com" or "www.example.com".'),
1515
+ hostname: z.string().describe(
1516
+ '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.'
1517
+ ),
1473
1518
  verificationId: z.string().optional().describe("Check an existing DNS verification instead of starting a new one.")
1474
1519
  }
1475
1520
  },
@@ -1479,12 +1524,12 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1479
1524
  if (args.verificationId !== void 0) {
1480
1525
  const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
1481
1526
  if (res2.status === "verified") {
1482
- writeSiteFile(ctx.projectDir, { ...site, billingDomain: res2.apexDomain });
1527
+ writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
1483
1528
  }
1484
1529
  return text(
1485
1530
  `DNS verification ${res2.verificationId}: ${res2.status}
1486
1531
  ${res2.message}
1487
- ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificate and CDN setup are in progress; check again with bind_domain + verificationId later.
1532
+ ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
1488
1533
  ` : "") + (res2.pendingDnsRecords.length > 0 ? `
1489
1534
  DNS records still required:
1490
1535
  ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
@@ -1495,25 +1540,16 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
1495
1540
  hostname: args.hostname
1496
1541
  };
1497
1542
  const res = await ctx.client.bindDomain(site.credential, req);
1498
- writeSiteFile(ctx.projectDir, {
1499
- ...site,
1500
- billingDomain: res.apexDomain,
1501
- billingDomainId: res.billingDomainId
1502
- });
1503
- 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.`;
1504
1543
  return text(
1505
- `Domain binding started for ${res.hostname}.
1506
-
1507
- Billing subject: the apex domain "${res.apexDomain}". Subdomains of ${res.apexDomain} cannot be separate billing subjects \u2014 they all share this domain hosting subscription.
1544
+ `Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
1508
1545
 
1509
1546
  1. Prove control of ${res.apexDomain} by creating this DNS record:
1510
1547
  name: ${res.verificationRecord.name}
1511
1548
  type: ${res.verificationRecord.type}
1512
1549
  value: ${res.verificationRecord.value}
1513
- Ownership and management authority come ONLY from DNS control. Paying for the subscription never grants ownership, management rights, or activation.
1550
+ 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.
1514
1551
 
1515
- 2. Serving DNS (after verification): ${servingHint}
1516
- Server guidance: ${res.servingInstructions}
1552
+ 2. Serving DNS (after verification): ${res.servingInstructions}
1517
1553
 
1518
1554
  Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`
1519
1555
  );
@@ -1525,33 +1561,24 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
1525
1561
  server.registerTool(
1526
1562
  "billing_status",
1527
1563
  {
1528
- 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.",
1529
- inputSchema: {
1530
- domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json.")
1531
- }
1564
+ 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).",
1565
+ inputSchema: {}
1532
1566
  },
1533
- async (args) => {
1567
+ async () => {
1534
1568
  try {
1535
- const site = readSiteFile(ctx.projectDir);
1536
- const domain = args.domain ?? site?.billingDomain;
1537
- if (!domain) {
1538
- throw new SakupaError(
1539
- "invalid_request",
1540
- 'No domain given and this project has no bound billing domain. Pass domain, e.g. billing_status { domain: "example.com" }.'
1541
- );
1542
- }
1543
- const res = await ctx.client.getBillingStatus(domain, site?.credential);
1569
+ const site = requireSiteFile(ctx);
1570
+ const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
1544
1571
  const lines = [
1545
- `Billing status for ${res.paidSubject} (service status: ${res.serviceStatus})`,
1546
- res.committedTier ? `Plan: ${res.committedTier} (\xA5${tierPriceJpy(res.committedTier)}/month)` : "Plan: (no subscription yet)",
1572
+ `Billing status for site ${res.siteId} (mode: ${res.mode})`,
1573
+ res.permanentUrl ? `Permanent URL: ${res.permanentUrl}` : void 0,
1574
+ res.plan ? `Plan: ${res.plan} (\xA5${res.monthlyPriceJpy ?? tierPriceJpy(res.plan)}/month)` : "Plan: (no subscription yet)",
1547
1575
  res.subscriptionStatus ? `Subscription payment state: ${res.subscriptionStatus}` : void 0,
1548
- res.cancelAtCycleEnd ? "Renewal: CANCELED \u2014 hosting stops at the end of the already-paid month" : void 0,
1549
- res.currentCycleStart ? `Current paid period: ${res.currentCycleStart} -> ${res.currentCycleEnd ?? "?"}` : void 0,
1576
+ res.cancelAtPeriodEnd ? "Renewal: CANCELED \u2014 the site reverts to free at the end of the already-paid month" : void 0,
1577
+ res.currentPeriodStart ? `Current paid period: ${res.currentPeriodStart} -> ${res.currentPeriodEnd ?? "?"}` : void 0,
1550
1578
  res.estimatedUsageTier ? `Estimated usage tier: ${res.estimatedUsageTier}` : void 0,
1551
1579
  res.lastReconciledAt ? `Last usage reconciliation: ${res.lastReconciledAt}` : void 0,
1552
- res.nextSettlementAt ? `Next renewal: ${res.nextSettlementAt}` : void 0,
1553
- res.risks.unpaid ? "ATTENTION: renewal payment failed \u2014 update the payment method (manage_billing) to keep the custom domain hosted." : void 0,
1554
- res.ownerView ? void 0 : "Note: public view (no valid owner credential presented)."
1580
+ res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
1581
+ 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
1555
1582
  ].filter((l) => l !== void 0);
1556
1583
  return textJson(`${lines.join("\n")}
1557
1584
 
@@ -1561,71 +1588,18 @@ Full status:`, res);
1561
1588
  }
1562
1589
  }
1563
1590
  );
1564
- server.registerTool(
1565
- "subscribe_domain",
1566
- {
1567
- 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.`,
1568
- inputSchema: {
1569
- domain: z.string().describe('Apex domain to subscribe, e.g. "example.com".'),
1570
- plan: planEnum.describe(
1571
- "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
1572
- ),
1573
- confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
1574
- }
1575
- },
1576
- async (args) => {
1577
- try {
1578
- const site = requireSiteFile(ctx);
1579
- if (args.confirm !== true) {
1580
- return text(
1581
- `${subscriptionWarning(args.domain, args.plan)}
1582
-
1583
- 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.`
1584
- );
1585
- }
1586
- const res = await ctx.client.createPlanCheckout(
1587
- {
1588
- siteId: site.siteId,
1589
- domain: args.domain,
1590
- plan: args.plan,
1591
- idempotencyKey: randomUUID(),
1592
- confirmPlan: true
1593
- },
1594
- site.credential
1595
- );
1596
- return text(
1597
- `Stripe Checkout link \u2014 Sakupa Domain Hosting for ${res.apexDomain}: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
1598
- ${res.checkoutUrl}
1599
-
1600
- 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.
1601
- 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.`
1602
- );
1603
- } catch (e) {
1604
- return toolError(e);
1605
- }
1606
- }
1607
- );
1608
1591
  server.registerTool(
1609
1592
  "manage_billing",
1610
1593
  {
1611
- 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.",
1612
- inputSchema: {
1613
- domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json.")
1614
- }
1594
+ 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).",
1595
+ inputSchema: {}
1615
1596
  },
1616
- async (args) => {
1597
+ async () => {
1617
1598
  try {
1618
1599
  const site = requireSiteFile(ctx);
1619
- const domain = args.domain ?? site.billingDomain;
1620
- if (!domain) {
1621
- throw new SakupaError(
1622
- "invalid_request",
1623
- "No domain given and this project has no bound billing domain. Pass domain explicitly."
1624
- );
1625
- }
1626
- const res = await ctx.client.createBillingPortal(domain, site.credential);
1600
+ const res = await ctx.client.createBillingPortal(site.siteId, site.credential);
1627
1601
  return text(
1628
- `Stripe billing portal for ${res.apexDomain}:
1602
+ `Stripe billing portal for this site:
1629
1603
  ${res.portalUrl}
1630
1604
 
1631
1605
  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.`
@@ -1638,7 +1612,7 @@ Open this link in a browser to update the payment method, view invoices, or mana
1638
1612
  server.registerTool(
1639
1613
  "recover_domain_site",
1640
1614
  {
1641
- 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).",
1615
+ 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).",
1642
1616
  inputSchema: {
1643
1617
  hostname: z.string().describe('Hostname of the site to recover, e.g. "www.example.com".'),
1644
1618
  verificationId: z.string().optional().describe("Complete a recovery previously started for this hostname."),
@@ -1650,7 +1624,7 @@ Open this link in a browser to update the payment method, view invoices, or mana
1650
1624
  if (args.verificationId === void 0) {
1651
1625
  const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
1652
1626
  return text(
1653
- `Recovery started for ${args.hostname} (apex billing domain: ${res2.apexDomain}).
1627
+ `Recovery started for ${args.hostname} (apex domain: ${res2.apexDomain}).
1654
1628
 
1655
1629
  Create this DNS record to prove apex-domain control:
1656
1630
  name: ${res2.verificationRecord.name}
@@ -1669,18 +1643,18 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
1669
1643
  });
1670
1644
  writeSiteFile(ctx.projectDir, {
1671
1645
  siteId: res.siteId,
1672
- billingDomain: res.billingDomain,
1646
+ ...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
1673
1647
  credential: res.credential,
1674
1648
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1675
1649
  apiBaseUrl: ctx.apiBaseUrl
1676
1650
  });
1677
1651
  return text(
1678
- `Recovery complete for ${res.billingDomain}.
1652
+ `Recovery complete.
1679
1653
  Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
1680
1654
  Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
1681
1655
 
1682
1656
  A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
1683
-
1657
+ ` + credentialGitReminder(ctx.projectDir) + `
1684
1658
  Download the current site content (signed URL):
1685
1659
  ${res.archiveUrl}`
1686
1660
  );
@@ -1692,39 +1666,32 @@ ${res.archiveUrl}`
1692
1666
  server.registerTool(
1693
1667
  "set_billing_plan",
1694
1668
  {
1695
- 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.`,
1669
+ 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.`,
1696
1670
  inputSchema: {
1697
- domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json."),
1698
1671
  plan: planEnum.optional().describe("Target monthly plan."),
1699
1672
  cancelRenewal: z.boolean().optional().describe(
1700
- "true: cancel renewal (hosting runs to the end of the paid month, then stops). false: re-enable renewal."
1673
+ "true: cancel renewal (the site stays permanent to the end of the paid month, then reverts to free). false: re-enable renewal."
1701
1674
  ),
1675
+ cancelNow: z.boolean().optional().describe("End the subscription immediately; the site reverts to free right away."),
1702
1676
  confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
1703
1677
  }
1704
1678
  },
1705
1679
  async (args) => {
1706
1680
  try {
1707
1681
  const site = requireSiteFile(ctx);
1708
- const domain = args.domain ?? site.billingDomain;
1709
- if (!domain) {
1710
- throw new SakupaError(
1711
- "invalid_request",
1712
- "No domain given and this project has no bound billing domain. Pass domain explicitly."
1713
- );
1714
- }
1715
1682
  const req = {
1716
- siteId: site.siteId,
1717
- ...args.plan !== void 0 ? { committedTier: args.plan } : {},
1683
+ ...args.plan !== void 0 ? { plan: args.plan } : {},
1718
1684
  ...args.cancelRenewal !== void 0 ? { cancelRenewal: args.cancelRenewal } : {},
1685
+ ...args.cancelNow !== void 0 ? { cancelNow: args.cancelNow } : {},
1719
1686
  confirm: args.confirm === true
1720
1687
  };
1721
1688
  try {
1722
- const res = await ctx.client.setBillingPlan(domain, site.credential, req);
1689
+ const res = await ctx.client.setBillingPlan(site.siteId, site.credential, req);
1723
1690
  return textJson(
1724
- `Billing plan updated for ${res.apexDomain}.
1691
+ `Billing updated for site ${res.siteId} (mode: ${res.mode}).
1725
1692
  Consequences:
1726
1693
  ${res.consequences.map((c) => `- ${c}`).join("\n")}
1727
- Applied plan:`,
1694
+ Result:`,
1728
1695
  res
1729
1696
  );
1730
1697
  } catch (e) {
@@ -1744,50 +1711,6 @@ ${e.message}
1744
1711
  }
1745
1712
  }
1746
1713
  );
1747
- server.registerTool(
1748
- "downgrade_to_free",
1749
- {
1750
- 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.",
1751
- inputSchema: {
1752
- immediateStopServing: z.boolean().optional().describe(
1753
- "Also stop custom-domain serving immediately (does not remove current-cycle charges)."
1754
- ),
1755
- confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
1756
- }
1757
- },
1758
- async (args) => {
1759
- try {
1760
- const site = requireSiteFile(ctx);
1761
- try {
1762
- const res = await ctx.client.downgradeSite(site.siteId, site.credential, {
1763
- ...args.immediateStopServing !== void 0 ? { immediateStopServing: args.immediateStopServing } : {},
1764
- confirm: args.confirm === true
1765
- });
1766
- return text(
1767
- `Downgrade scheduled for site ${res.siteId}.
1768
- Effective at: ${res.effectiveAt}
1769
- Immediate stop-serving applied: ${res.immediateStopApplied ? "yes" : "no"}
1770
- ${res.message}
1771
-
1772
- 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).`
1773
- );
1774
- } catch (e) {
1775
- if (isSakupaError(e) && e.code === "confirmation_required") {
1776
- return text(
1777
- `Confirmation required before downgrading \u2014 nothing was applied.
1778
-
1779
- ${e.message}
1780
- ` + (e.details !== void 0 ? `${JSON.stringify(e.details, null, 2)}
1781
- ` : "") + "\nPlease show these consequences to the user and, after their explicit confirmation, re-run downgrade_to_free with confirm: true."
1782
- );
1783
- }
1784
- throw e;
1785
- }
1786
- } catch (e) {
1787
- return toolError(e);
1788
- }
1789
- }
1790
- );
1791
1714
  server.registerTool(
1792
1715
  "create_support_ticket",
1793
1716
  {
@@ -1818,7 +1741,7 @@ ${e.message}
1818
1741
  server.registerTool(
1819
1742
  "report_bug",
1820
1743
  {
1821
- 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.",
1744
+ 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.",
1822
1745
  inputSchema: {
1823
1746
  toolName: z.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
1824
1747
  errorCode: z.string().optional(),
@@ -1838,7 +1761,7 @@ ${e.message}
1838
1761
  ...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
1839
1762
  ...args.errorMessage !== void 0 ? { sanitizedErrorMessage: args.errorMessage } : {},
1840
1763
  ...site ? { siteId: site.siteId } : {},
1841
- ...site?.billingDomain ? { billingDomain: site.billingDomain } : {},
1764
+ ...site?.boundDomain ? { boundDomain: site.boundDomain } : {},
1842
1765
  ...args.deploymentId !== void 0 ? { deploymentId: args.deploymentId } : {},
1843
1766
  occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
1844
1767
  clientType: CLIENT_TYPE,
@@ -1881,19 +1804,23 @@ Workflow:
1881
1804
  site (public URL {shortId}.sakupa.com, valid 24 hours, free banner shown) and stores the
1882
1805
  management credential in .sakupa/site.json. Deploying again updates the site and refreshes
1883
1806
  its validity; refresh_site extends validity without uploading.
1884
- 3. For long-term use, subscribe the apex domain to a monthly hosting plan (subscribe_domain ->
1807
+ 3. To make the site PERMANENT, subscribe it to a monthly hosting plan (subscribe_site ->
1885
1808
  Stripe-hosted checkout; water/personal/share/business, auto-upgrade when the site outgrows
1886
- its plan), then bind the custom domain (bind_domain): the billing subject is always the
1887
- registered apex domain and ownership is proven only by DNS control. billing_status,
1888
- set_billing_plan, manage_billing, downgrade_to_free and recover_domain_site manage the
1889
- paid lifecycle.
1809
+ its plan). Paying makes the {shortId}.sakupa.com URL permanent \u2014 that is what payment buys.
1810
+ 4. Optionally bind a custom domain to the subscribed site (bind_domain): an included extra
1811
+ serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
1812
+ first verified request wins; unverified requests expire after 72 hours. billing_status,
1813
+ set_billing_plan, manage_billing and recover_domain_site manage the paid lifecycle.
1814
+ Canceling the subscription immediately reverts the site to a free 24h site and removes all
1815
+ paid data.
1890
1816
 
1891
1817
  Safety boundaries:
1892
1818
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
1893
1819
  - Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
1894
1820
  - Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
1895
- - A subscription is payment state only; it never grants domain ownership or management rights.
1896
- - The management credential lives only in .sakupa/site.json; never share or upload it.`;
1821
+ - A subscription never grants domain ownership; only DNS verification does.
1822
+ - The management credential lives only in .sakupa/site.json; never share or upload it. Without
1823
+ a bound custom domain, a lost credential is unrecoverable by design.`;
1897
1824
  function createSakupaMcpServer(opts) {
1898
1825
  const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
1899
1826
  const server = new McpServer(