@sakupa/mcp 0.7.8 → 0.7.9

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 +187 -30
  2. package/dist/index.js +187 -30
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -129,7 +129,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
129
129
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
130
130
 
131
131
  // ../core/dist/domain/version.js
132
- var SAKUPA_MCP_VERSION = "0.7.8";
132
+ var SAKUPA_MCP_VERSION = "0.7.9";
133
133
 
134
134
  // ../core/dist/domain/errors.js
135
135
  var HTTP_STATUS = {
@@ -458,10 +458,10 @@ var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
458
458
  // src/config.ts
459
459
  var TEST_API_BASE_URL = "https://api-test.sakupa.com";
460
460
  function previewHostPatternFor(apiBaseUrl) {
461
- return apiBaseUrl === TEST_API_BASE_URL ? "{shortId}-test.sakupa.com" : "{shortId}.sakupa.com";
461
+ return environmentFor(apiBaseUrl) === "test" ? "{shortId}-test.sakupa.com" : "{shortId}.sakupa.com";
462
462
  }
463
463
  function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
464
- const apiBaseUrl = (env["SAKUPA_API_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
464
+ const apiBaseUrl = (env["SAKUPA_API_URL"] ?? env["SAKUPA_API_BASE_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
465
465
  const projectDir = env["SAKUPA_PROJECT_DIR"] ?? cwd;
466
466
  const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
467
467
  if (apiBaseUrl === TEST_API_BASE_URL) {
@@ -479,6 +479,9 @@ function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
479
479
  }
480
480
  return { apiBaseUrl, projectDir };
481
481
  }
482
+ function environmentFor(apiBaseUrl) {
483
+ return apiBaseUrl === TEST_API_BASE_URL ? "test" : "production";
484
+ }
482
485
 
483
486
  // src/server.ts
484
487
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -591,11 +594,14 @@ var HttpApiClient = class {
591
594
  async bindDomain(credential, req) {
592
595
  return this.call("POST", "/v1/domains/bind", { credential, body: req });
593
596
  }
594
- async checkVerification(verificationId, credential) {
597
+ async checkVerification(verificationId, credential, latestForSiteId) {
595
598
  return this.call(
596
599
  "POST",
597
600
  `/v1/domains/verifications/${encodeURIComponent(verificationId)}/check`,
598
- { credential }
601
+ {
602
+ credential,
603
+ ...latestForSiteId !== void 0 ? { body: { siteId: latestForSiteId } } : {}
604
+ }
599
605
  );
600
606
  }
601
607
  async unbindDomain(siteId, credential, req) {
@@ -1248,6 +1254,111 @@ function removeCreation(siteId) {
1248
1254
  if (rest.length !== all.length) writeAll(rest);
1249
1255
  }
1250
1256
 
1257
+ // src/dns-doh.ts
1258
+ var dohFetch = (input, init) => fetch(input, init);
1259
+ var TYPE_CODES = { TXT: 16, CNAME: 5, A: 1 };
1260
+ async function resolveDns(name, type) {
1261
+ const endpoints = [
1262
+ `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`,
1263
+ `https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`
1264
+ ];
1265
+ for (const url of endpoints) {
1266
+ try {
1267
+ const res = await dohFetch(url, { headers: { accept: "application/dns-json" } });
1268
+ if (!res.ok) continue;
1269
+ const body = await res.json();
1270
+ return (body.Answer ?? []).filter((a) => a.type === TYPE_CODES[type]).map((a) => a.data.replace(/^"|"$/g, "").replace(/"\s+"/g, "")).map((v) => type === "CNAME" ? v.replace(/\.$/, "").toLowerCase() : v);
1271
+ } catch {
1272
+ }
1273
+ }
1274
+ return [];
1275
+ }
1276
+ function shortHostFor(fullName, apexDomain) {
1277
+ const suffix = `.${apexDomain}`;
1278
+ if (fullName === apexDomain) return "@";
1279
+ return fullName.endsWith(suffix) ? fullName.slice(0, -suffix.length) : fullName;
1280
+ }
1281
+ function matches(record, values) {
1282
+ if (record.type === "CNAME") {
1283
+ const want = record.value.replace(/\.$/, "").toLowerCase();
1284
+ return values.some((v) => v === want);
1285
+ }
1286
+ return values.includes(record.value);
1287
+ }
1288
+ async function checkRecord(record, apexDomain) {
1289
+ const shortHost = shortHostFor(record.name, apexDomain);
1290
+ const found = await resolveDns(record.name, record.type);
1291
+ if (matches(record, found)) {
1292
+ return { record, shortHost, state: "ok", found, fix: "" };
1293
+ }
1294
+ const doubled = await resolveDns(`${record.name}.${apexDomain}`, record.type);
1295
+ if (matches(record, doubled)) {
1296
+ return {
1297
+ record,
1298
+ shortHost,
1299
+ state: "double_domain",
1300
+ found: doubled,
1301
+ fix: `The record exists at ${record.name}.${apexDomain} \u2014 the host field was filled with the full name and your DNS panel appended ${apexDomain} again. Edit that record's host to exactly: ${shortHost}`
1302
+ };
1303
+ }
1304
+ if (found.length > 0) {
1305
+ return {
1306
+ record,
1307
+ shortHost,
1308
+ state: "wrong_value",
1309
+ found,
1310
+ fix: `A ${record.type} record exists at ${record.name} but its value is ${JSON.stringify(found)} instead of "${record.value}". Update the value exactly.`
1311
+ };
1312
+ }
1313
+ return {
1314
+ record,
1315
+ shortHost,
1316
+ state: "missing",
1317
+ found,
1318
+ fix: `Create it now \u2014 type: ${record.type}, host: ${shortHost} (most panels append .${apexDomain} automatically; if yours wants the full name use ${record.name}), value: ${record.value}`
1319
+ };
1320
+ }
1321
+ function renderCheck(c) {
1322
+ const label = `${c.record.type} ${c.shortHost}`;
1323
+ switch (c.state) {
1324
+ case "ok":
1325
+ return ` [OK] ${label} \u2014 live on public DNS.`;
1326
+ case "double_domain":
1327
+ return ` [FIX] ${label} \u2014 ${c.fix}`;
1328
+ case "wrong_value":
1329
+ return ` [FIX] ${label} \u2014 ${c.fix}`;
1330
+ case "missing":
1331
+ return ` [MISSING] ${label} \u2014 ${c.fix}`;
1332
+ }
1333
+ }
1334
+ async function diagnoseBinding(input) {
1335
+ const apex = input.apexDomain;
1336
+ const byKey = /* @__PURE__ */ new Map();
1337
+ if (input.verificationRecord) {
1338
+ byKey.set(`TXT:${input.verificationRecord.name}`, input.verificationRecord);
1339
+ }
1340
+ const www = {
1341
+ name: `www.${apex}`,
1342
+ type: "CNAME",
1343
+ value: input.servingTarget
1344
+ };
1345
+ byKey.set(`CNAME:${www.name}`, www);
1346
+ for (const rec of input.pendingDnsRecords) {
1347
+ const key = `${rec.type}:${rec.name}`;
1348
+ if (!byKey.has(key)) byKey.set(key, rec);
1349
+ }
1350
+ const [checks, apexAnswers] = await Promise.all([
1351
+ Promise.all([...byKey.values()].map((rec) => checkRecord(rec, apex))),
1352
+ resolveDns(apex, "A")
1353
+ ]);
1354
+ const apexResolves = apexAnswers.length > 0;
1355
+ const allOk = checks.every((c) => c.state === "ok");
1356
+ const checklist = checks.map(renderCheck).join("\n") + `
1357
+ [${apexResolves ? "OK" : "MISSING"}] APEX ${apex} \u2014 ` + (apexResolves ? "resolves." : `does not resolve yet: point it at ${input.servingTarget} using your DNS panel's ALIAS / ANAME / CNAME-flattening feature (an apex cannot use a plain CNAME).`);
1358
+ const layers = `Pipeline: [1] public DNS (checked LIVE above) -> [2] Sakupa ownership verification: ${input.verificationStatus} -> [3] HTTPS certificate & serving: ` + (input.provisioning ? "provisioning (Cloudflare validates and issues within minutes once the records above are all OK; Sakupa retries automatically every ~5 minutes)." : "starts after verification.");
1359
+ return { checks, apexResolves, allOk, checklist, layers };
1360
+ }
1361
+
1251
1362
  // src/version.ts
1252
1363
  var MCP_VERSION = SAKUPA_MCP_VERSION;
1253
1364
  var CLIENT_TYPE = "sakupa-mcp";
@@ -1498,6 +1609,8 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1498
1609
  }
1499
1610
  return targets.length;
1500
1611
  }
1612
+ var DNS_RETRY_AFTER_SECONDS = 300;
1613
+ var DNS_MAX_ATTEMPTS = 10;
1501
1614
  function freeSiteCreationBarrier() {
1502
1615
  const recent = listRecentCreations(Date.now());
1503
1616
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -1628,6 +1741,7 @@ Next action: ${analysis.suggestedNextAction}`,
1628
1741
  return text(
1629
1742
  "site_published",
1630
1743
  `Site published: ${finalized2.url}
1744
+ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
1631
1745
  Project directory: ${ctx.projectDir}
1632
1746
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1633
1747
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
@@ -1647,7 +1761,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1647
1761
  totalBytes: finalized2.totalBytes,
1648
1762
  warnings: finalized2.warnings,
1649
1763
  credentialStoredLocally: true,
1650
- projectDir: ctx.projectDir
1764
+ projectDir: ctx.projectDir,
1765
+ environment: environmentFor(ctx.apiBaseUrl)
1651
1766
  }
1652
1767
  );
1653
1768
  }
@@ -1693,6 +1808,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1693
1808
  return text(
1694
1809
  "site_updated",
1695
1810
  `Site updated: ${finalized.url}
1811
+ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
1696
1812
  Project directory: ${ctx.projectDir}
1697
1813
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1698
1814
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
@@ -1706,6 +1822,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1706
1822
  url: finalized.url,
1707
1823
  mode: finalized.mode,
1708
1824
  projectDir: ctx.projectDir,
1825
+ environment: environmentFor(ctx.apiBaseUrl),
1709
1826
  expiresAt: finalized.expiresAt,
1710
1827
  filesUploaded: uploaded,
1711
1828
  totalBytes: finalized.totalBytes,
@@ -1820,7 +1937,9 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1820
1937
  projectDir: projectDirInput,
1821
1938
  action: z3.enum(["start", "status"]),
1822
1939
  hostname: z3.string().optional().describe("Required for start."),
1823
- verificationId: z3.string().optional().describe("Required for status.")
1940
+ verificationId: z3.string().optional().describe(
1941
+ "Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
1942
+ )
1824
1943
  }
1825
1944
  },
1826
1945
  async (args) => {
@@ -1828,30 +1947,49 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1828
1947
  const ctx = withProjectDir(baseCtx, args.projectDir);
1829
1948
  const site = requireSiteFile(ctx);
1830
1949
  if (args.action === "status") {
1831
- if (!args.verificationId) {
1832
- throw new SakupaError("invalid_request", "verificationId is required for status");
1833
- }
1834
- const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
1950
+ const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
1835
1951
  if (res2.status === "verified") {
1836
1952
  writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
1837
1953
  }
1954
+ const apex2 = res2.apexDomain;
1955
+ const { checks, apexResolves, allOk, checklist, layers } = await diagnoseBinding({
1956
+ apexDomain: apex2,
1957
+ servingTarget: res2.servingTarget,
1958
+ ...res2.verificationRecord ? { verificationRecord: res2.verificationRecord } : {},
1959
+ pendingDnsRecords: res2.pendingDnsRecords,
1960
+ verificationStatus: res2.status,
1961
+ provisioning: res2.provisioningJobId !== void 0
1962
+ });
1838
1963
  return text(
1839
1964
  res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
1840
- `DNS verification ${res2.verificationId}: ${res2.status}
1965
+ `Domain binding status for ${apex2}: ${res2.status}
1841
1966
  ${res2.message}
1842
- ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
1843
- ` : "") + (res2.pendingDnsRecords.length > 0 ? `
1844
- DNS records still required:
1845
- ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
1967
+
1968
+ Live DNS checklist (host values are the SHORT panel form):
1969
+ ${checklist}
1970
+
1971
+ ${layers}
1972
+
1973
+ ` + (allOk && res2.status === "verified" ? "All records are live; certificate issuance completes automatically \u2014 check again in a few minutes until the binding is active." : "Fix any [MISSING]/[FIX] lines above, then re-run bind_domain status. Re-check every 5 minutes, up to 10 times; if still failing after that, show the user this checklist."),
1846
1974
  {
1847
1975
  verificationId: res2.verificationId,
1848
1976
  status: res2.status,
1849
- apexDomain: res2.apexDomain,
1977
+ apexDomain: apex2,
1850
1978
  provisioningJobId: res2.provisioningJobId,
1851
- pendingDnsRecords: res2.pendingDnsRecords,
1979
+ servingTarget: res2.servingTarget,
1980
+ dnsChecklist: checks.map((c) => ({
1981
+ name: c.record.name,
1982
+ type: c.record.type,
1983
+ shortHost: c.shortHost,
1984
+ state: c.state,
1985
+ fix: c.fix || void 0
1986
+ })),
1987
+ apexResolves,
1988
+ retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
1989
+ maxAttempts: DNS_MAX_ATTEMPTS,
1852
1990
  message: res2.message
1853
1991
  },
1854
- res2.status === "verified" ? "completed" : "pending_provider"
1992
+ res2.status === "verified" ? "pending_provider" : "waiting_user"
1855
1993
  );
1856
1994
  }
1857
1995
  if (!args.hostname) {
@@ -1862,25 +2000,42 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
1862
2000
  hostname: args.hostname
1863
2001
  };
1864
2002
  const res = await ctx.client.bindDomain(site.credential, req);
2003
+ const apex = res.apexDomain;
2004
+ const txtShort = shortHostFor(res.verificationRecord.name, apex);
1865
2005
  return text(
1866
2006
  "domain_verification_started",
1867
- `Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
2007
+ `Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
2008
+
2009
+ Add ALL THREE DNS records NOW (adding them together lets verification, certificate issuance and serving complete without further record changes):
1868
2010
 
1869
- 1. Prove control of ${res.apexDomain} by creating this DNS record:
1870
- name: ${res.verificationRecord.name}
1871
- type: ${res.verificationRecord.type}
1872
- value: ${res.verificationRecord.value}
1873
- 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.
2011
+ 1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
2012
+ 2) CNAME host: www value: ${res.servingTarget}
2013
+ 3) APEX host: @ -> ${res.servingTarget} via your DNS panel's ALIAS / ANAME / CNAME-flattening feature (an apex cannot use a plain CNAME).
1874
2014
 
1875
- 2. Serving DNS (after verification): ${res.servingInstructions}
2015
+ Host fields above are the SHORT form: most DNS panels append the domain automatically. After saving, the record list must NOT show ${apex} twice in one name \u2014 that means the full name was pasted into an auto-appending field.
1876
2016
 
1877
- Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`,
2017
+ Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
2018
+
2019
+ Then run bind_domain with action "status" \u2014 it live-checks every record and names the exact fix for anything wrong. Re-check every 5 minutes (up to 10 times). Any later session can resume with action "status" alone; the verificationId is optional.`,
1878
2020
  {
1879
2021
  verificationId: res.verificationId,
1880
- apexDomain: res.apexDomain,
2022
+ apexDomain: apex,
1881
2023
  includedHostnames: res.includedHostnames,
1882
2024
  verificationRecord: res.verificationRecord,
1883
- servingInstructions: res.servingInstructions
2025
+ servingTarget: res.servingTarget,
2026
+ requiredRecords: [
2027
+ {
2028
+ type: "TXT",
2029
+ shortHost: txtShort,
2030
+ name: res.verificationRecord.name,
2031
+ value: res.verificationRecord.value
2032
+ },
2033
+ { type: "CNAME", shortHost: "www", name: `www.${apex}`, value: res.servingTarget },
2034
+ { type: "ALIAS", shortHost: "@", name: apex, value: res.servingTarget }
2035
+ ],
2036
+ servingInstructions: res.servingInstructions,
2037
+ retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
2038
+ maxAttempts: DNS_MAX_ATTEMPTS
1884
2039
  },
1885
2040
  "waiting_user"
1886
2041
  );
@@ -2503,7 +2658,9 @@ Project directory contract: ONE directory = ONE site (its .sakupa/site.json hold
2503
2658
  binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2504
2659
  the directory the user is currently working in, on every call. Without it the server falls
2505
2660
  back to its startup directory, which may be a different project than the one the user is
2506
- looking at. analyze_site, deploy_site, site_status, refresh_site, delete_site and unbind_domain echo
2661
+ looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2662
+ Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
2663
+ refresh_site, delete_site and unbind_domain echo
2507
2664
  the directory they acted on \u2014 verify it matches the user's active project.
2508
2665
 
2509
2666
  Safety boundaries:
package/dist/index.js CHANGED
@@ -124,7 +124,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
124
124
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
125
125
 
126
126
  // ../core/dist/domain/version.js
127
- var SAKUPA_MCP_VERSION = "0.7.8";
127
+ var SAKUPA_MCP_VERSION = "0.7.9";
128
128
 
129
129
  // ../core/dist/domain/errors.js
130
130
  var HTTP_STATUS = {
@@ -457,10 +457,10 @@ var CLIENT_TYPE = "sakupa-mcp";
457
457
  // src/config.ts
458
458
  var TEST_API_BASE_URL = "https://api-test.sakupa.com";
459
459
  function previewHostPatternFor(apiBaseUrl) {
460
- return apiBaseUrl === TEST_API_BASE_URL ? "{shortId}-test.sakupa.com" : "{shortId}.sakupa.com";
460
+ return environmentFor(apiBaseUrl) === "test" ? "{shortId}-test.sakupa.com" : "{shortId}.sakupa.com";
461
461
  }
462
462
  function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
463
- const apiBaseUrl = (env["SAKUPA_API_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
463
+ const apiBaseUrl = (env["SAKUPA_API_URL"] ?? env["SAKUPA_API_BASE_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
464
464
  const projectDir = env["SAKUPA_PROJECT_DIR"] ?? cwd;
465
465
  const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
466
466
  if (apiBaseUrl === TEST_API_BASE_URL) {
@@ -478,6 +478,9 @@ function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
478
478
  }
479
479
  return { apiBaseUrl, projectDir };
480
480
  }
481
+ function environmentFor(apiBaseUrl) {
482
+ return apiBaseUrl === TEST_API_BASE_URL ? "test" : "production";
483
+ }
481
484
 
482
485
  // src/transport.ts
483
486
  var FetchTransport = class {
@@ -659,11 +662,14 @@ var HttpApiClient = class {
659
662
  async bindDomain(credential, req) {
660
663
  return this.call("POST", "/v1/domains/bind", { credential, body: req });
661
664
  }
662
- async checkVerification(verificationId, credential) {
665
+ async checkVerification(verificationId, credential, latestForSiteId) {
663
666
  return this.call(
664
667
  "POST",
665
668
  `/v1/domains/verifications/${encodeURIComponent(verificationId)}/check`,
666
- { credential }
669
+ {
670
+ credential,
671
+ ...latestForSiteId !== void 0 ? { body: { siteId: latestForSiteId } } : {}
672
+ }
667
673
  );
668
674
  }
669
675
  async unbindDomain(siteId, credential, req) {
@@ -1456,6 +1462,111 @@ function removeCreation(siteId) {
1456
1462
  if (rest.length !== all.length) writeAll(rest);
1457
1463
  }
1458
1464
 
1465
+ // src/dns-doh.ts
1466
+ var dohFetch = (input, init) => fetch(input, init);
1467
+ var TYPE_CODES = { TXT: 16, CNAME: 5, A: 1 };
1468
+ async function resolveDns(name, type) {
1469
+ const endpoints = [
1470
+ `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`,
1471
+ `https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`
1472
+ ];
1473
+ for (const url of endpoints) {
1474
+ try {
1475
+ const res = await dohFetch(url, { headers: { accept: "application/dns-json" } });
1476
+ if (!res.ok) continue;
1477
+ const body = await res.json();
1478
+ return (body.Answer ?? []).filter((a) => a.type === TYPE_CODES[type]).map((a) => a.data.replace(/^"|"$/g, "").replace(/"\s+"/g, "")).map((v) => type === "CNAME" ? v.replace(/\.$/, "").toLowerCase() : v);
1479
+ } catch {
1480
+ }
1481
+ }
1482
+ return [];
1483
+ }
1484
+ function shortHostFor(fullName, apexDomain) {
1485
+ const suffix = `.${apexDomain}`;
1486
+ if (fullName === apexDomain) return "@";
1487
+ return fullName.endsWith(suffix) ? fullName.slice(0, -suffix.length) : fullName;
1488
+ }
1489
+ function matches(record, values) {
1490
+ if (record.type === "CNAME") {
1491
+ const want = record.value.replace(/\.$/, "").toLowerCase();
1492
+ return values.some((v) => v === want);
1493
+ }
1494
+ return values.includes(record.value);
1495
+ }
1496
+ async function checkRecord(record, apexDomain) {
1497
+ const shortHost = shortHostFor(record.name, apexDomain);
1498
+ const found = await resolveDns(record.name, record.type);
1499
+ if (matches(record, found)) {
1500
+ return { record, shortHost, state: "ok", found, fix: "" };
1501
+ }
1502
+ const doubled = await resolveDns(`${record.name}.${apexDomain}`, record.type);
1503
+ if (matches(record, doubled)) {
1504
+ return {
1505
+ record,
1506
+ shortHost,
1507
+ state: "double_domain",
1508
+ found: doubled,
1509
+ fix: `The record exists at ${record.name}.${apexDomain} \u2014 the host field was filled with the full name and your DNS panel appended ${apexDomain} again. Edit that record's host to exactly: ${shortHost}`
1510
+ };
1511
+ }
1512
+ if (found.length > 0) {
1513
+ return {
1514
+ record,
1515
+ shortHost,
1516
+ state: "wrong_value",
1517
+ found,
1518
+ fix: `A ${record.type} record exists at ${record.name} but its value is ${JSON.stringify(found)} instead of "${record.value}". Update the value exactly.`
1519
+ };
1520
+ }
1521
+ return {
1522
+ record,
1523
+ shortHost,
1524
+ state: "missing",
1525
+ found,
1526
+ fix: `Create it now \u2014 type: ${record.type}, host: ${shortHost} (most panels append .${apexDomain} automatically; if yours wants the full name use ${record.name}), value: ${record.value}`
1527
+ };
1528
+ }
1529
+ function renderCheck(c) {
1530
+ const label = `${c.record.type} ${c.shortHost}`;
1531
+ switch (c.state) {
1532
+ case "ok":
1533
+ return ` [OK] ${label} \u2014 live on public DNS.`;
1534
+ case "double_domain":
1535
+ return ` [FIX] ${label} \u2014 ${c.fix}`;
1536
+ case "wrong_value":
1537
+ return ` [FIX] ${label} \u2014 ${c.fix}`;
1538
+ case "missing":
1539
+ return ` [MISSING] ${label} \u2014 ${c.fix}`;
1540
+ }
1541
+ }
1542
+ async function diagnoseBinding(input) {
1543
+ const apex = input.apexDomain;
1544
+ const byKey = /* @__PURE__ */ new Map();
1545
+ if (input.verificationRecord) {
1546
+ byKey.set(`TXT:${input.verificationRecord.name}`, input.verificationRecord);
1547
+ }
1548
+ const www = {
1549
+ name: `www.${apex}`,
1550
+ type: "CNAME",
1551
+ value: input.servingTarget
1552
+ };
1553
+ byKey.set(`CNAME:${www.name}`, www);
1554
+ for (const rec of input.pendingDnsRecords) {
1555
+ const key = `${rec.type}:${rec.name}`;
1556
+ if (!byKey.has(key)) byKey.set(key, rec);
1557
+ }
1558
+ const [checks, apexAnswers] = await Promise.all([
1559
+ Promise.all([...byKey.values()].map((rec) => checkRecord(rec, apex))),
1560
+ resolveDns(apex, "A")
1561
+ ]);
1562
+ const apexResolves = apexAnswers.length > 0;
1563
+ const allOk = checks.every((c) => c.state === "ok");
1564
+ const checklist = checks.map(renderCheck).join("\n") + `
1565
+ [${apexResolves ? "OK" : "MISSING"}] APEX ${apex} \u2014 ` + (apexResolves ? "resolves." : `does not resolve yet: point it at ${input.servingTarget} using your DNS panel's ALIAS / ANAME / CNAME-flattening feature (an apex cannot use a plain CNAME).`);
1566
+ const layers = `Pipeline: [1] public DNS (checked LIVE above) -> [2] Sakupa ownership verification: ${input.verificationStatus} -> [3] HTTPS certificate & serving: ` + (input.provisioning ? "provisioning (Cloudflare validates and issues within minutes once the records above are all OK; Sakupa retries automatically every ~5 minutes)." : "starts after verification.");
1567
+ return { checks, apexResolves, allOk, checklist, layers };
1568
+ }
1569
+
1459
1570
  // src/tools/definitions.ts
1460
1571
  function text(resultCode, t, data = {}, outcome = "completed") {
1461
1572
  return structuredToolResult({
@@ -1562,6 +1673,8 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1562
1673
  }
1563
1674
  return targets.length;
1564
1675
  }
1676
+ var DNS_RETRY_AFTER_SECONDS = 300;
1677
+ var DNS_MAX_ATTEMPTS = 10;
1565
1678
  function freeSiteCreationBarrier() {
1566
1679
  const recent = listRecentCreations(Date.now());
1567
1680
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -1692,6 +1805,7 @@ Next action: ${analysis.suggestedNextAction}`,
1692
1805
  return text(
1693
1806
  "site_published",
1694
1807
  `Site published: ${finalized2.url}
1808
+ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
1695
1809
  Project directory: ${ctx.projectDir}
1696
1810
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1697
1811
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
@@ -1711,7 +1825,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1711
1825
  totalBytes: finalized2.totalBytes,
1712
1826
  warnings: finalized2.warnings,
1713
1827
  credentialStoredLocally: true,
1714
- projectDir: ctx.projectDir
1828
+ projectDir: ctx.projectDir,
1829
+ environment: environmentFor(ctx.apiBaseUrl)
1715
1830
  }
1716
1831
  );
1717
1832
  }
@@ -1757,6 +1872,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1757
1872
  return text(
1758
1873
  "site_updated",
1759
1874
  `Site updated: ${finalized.url}
1875
+ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
1760
1876
  Project directory: ${ctx.projectDir}
1761
1877
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1762
1878
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
@@ -1770,6 +1886,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1770
1886
  url: finalized.url,
1771
1887
  mode: finalized.mode,
1772
1888
  projectDir: ctx.projectDir,
1889
+ environment: environmentFor(ctx.apiBaseUrl),
1773
1890
  expiresAt: finalized.expiresAt,
1774
1891
  filesUploaded: uploaded,
1775
1892
  totalBytes: finalized.totalBytes,
@@ -1884,7 +2001,9 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1884
2001
  projectDir: projectDirInput,
1885
2002
  action: z3.enum(["start", "status"]),
1886
2003
  hostname: z3.string().optional().describe("Required for start."),
1887
- verificationId: z3.string().optional().describe("Required for status.")
2004
+ verificationId: z3.string().optional().describe(
2005
+ "Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
2006
+ )
1888
2007
  }
1889
2008
  },
1890
2009
  async (args) => {
@@ -1892,30 +2011,49 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1892
2011
  const ctx = withProjectDir(baseCtx, args.projectDir);
1893
2012
  const site = requireSiteFile(ctx);
1894
2013
  if (args.action === "status") {
1895
- if (!args.verificationId) {
1896
- throw new SakupaError("invalid_request", "verificationId is required for status");
1897
- }
1898
- const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
2014
+ const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
1899
2015
  if (res2.status === "verified") {
1900
2016
  writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
1901
2017
  }
2018
+ const apex2 = res2.apexDomain;
2019
+ const { checks, apexResolves, allOk, checklist, layers } = await diagnoseBinding({
2020
+ apexDomain: apex2,
2021
+ servingTarget: res2.servingTarget,
2022
+ ...res2.verificationRecord ? { verificationRecord: res2.verificationRecord } : {},
2023
+ pendingDnsRecords: res2.pendingDnsRecords,
2024
+ verificationStatus: res2.status,
2025
+ provisioning: res2.provisioningJobId !== void 0
2026
+ });
1902
2027
  return text(
1903
2028
  res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
1904
- `DNS verification ${res2.verificationId}: ${res2.status}
2029
+ `Domain binding status for ${apex2}: ${res2.status}
1905
2030
  ${res2.message}
1906
- ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
1907
- ` : "") + (res2.pendingDnsRecords.length > 0 ? `
1908
- DNS records still required:
1909
- ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
2031
+
2032
+ Live DNS checklist (host values are the SHORT panel form):
2033
+ ${checklist}
2034
+
2035
+ ${layers}
2036
+
2037
+ ` + (allOk && res2.status === "verified" ? "All records are live; certificate issuance completes automatically \u2014 check again in a few minutes until the binding is active." : "Fix any [MISSING]/[FIX] lines above, then re-run bind_domain status. Re-check every 5 minutes, up to 10 times; if still failing after that, show the user this checklist."),
1910
2038
  {
1911
2039
  verificationId: res2.verificationId,
1912
2040
  status: res2.status,
1913
- apexDomain: res2.apexDomain,
2041
+ apexDomain: apex2,
1914
2042
  provisioningJobId: res2.provisioningJobId,
1915
- pendingDnsRecords: res2.pendingDnsRecords,
2043
+ servingTarget: res2.servingTarget,
2044
+ dnsChecklist: checks.map((c) => ({
2045
+ name: c.record.name,
2046
+ type: c.record.type,
2047
+ shortHost: c.shortHost,
2048
+ state: c.state,
2049
+ fix: c.fix || void 0
2050
+ })),
2051
+ apexResolves,
2052
+ retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
2053
+ maxAttempts: DNS_MAX_ATTEMPTS,
1916
2054
  message: res2.message
1917
2055
  },
1918
- res2.status === "verified" ? "completed" : "pending_provider"
2056
+ res2.status === "verified" ? "pending_provider" : "waiting_user"
1919
2057
  );
1920
2058
  }
1921
2059
  if (!args.hostname) {
@@ -1926,25 +2064,42 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
1926
2064
  hostname: args.hostname
1927
2065
  };
1928
2066
  const res = await ctx.client.bindDomain(site.credential, req);
2067
+ const apex = res.apexDomain;
2068
+ const txtShort = shortHostFor(res.verificationRecord.name, apex);
1929
2069
  return text(
1930
2070
  "domain_verification_started",
1931
- `Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
2071
+ `Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
2072
+
2073
+ Add ALL THREE DNS records NOW (adding them together lets verification, certificate issuance and serving complete without further record changes):
1932
2074
 
1933
- 1. Prove control of ${res.apexDomain} by creating this DNS record:
1934
- name: ${res.verificationRecord.name}
1935
- type: ${res.verificationRecord.type}
1936
- value: ${res.verificationRecord.value}
1937
- 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.
2075
+ 1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
2076
+ 2) CNAME host: www value: ${res.servingTarget}
2077
+ 3) APEX host: @ -> ${res.servingTarget} via your DNS panel's ALIAS / ANAME / CNAME-flattening feature (an apex cannot use a plain CNAME).
1938
2078
 
1939
- 2. Serving DNS (after verification): ${res.servingInstructions}
2079
+ Host fields above are the SHORT form: most DNS panels append the domain automatically. After saving, the record list must NOT show ${apex} twice in one name \u2014 that means the full name was pasted into an auto-appending field.
1940
2080
 
1941
- Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`,
2081
+ Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
2082
+
2083
+ Then run bind_domain with action "status" \u2014 it live-checks every record and names the exact fix for anything wrong. Re-check every 5 minutes (up to 10 times). Any later session can resume with action "status" alone; the verificationId is optional.`,
1942
2084
  {
1943
2085
  verificationId: res.verificationId,
1944
- apexDomain: res.apexDomain,
2086
+ apexDomain: apex,
1945
2087
  includedHostnames: res.includedHostnames,
1946
2088
  verificationRecord: res.verificationRecord,
1947
- servingInstructions: res.servingInstructions
2089
+ servingTarget: res.servingTarget,
2090
+ requiredRecords: [
2091
+ {
2092
+ type: "TXT",
2093
+ shortHost: txtShort,
2094
+ name: res.verificationRecord.name,
2095
+ value: res.verificationRecord.value
2096
+ },
2097
+ { type: "CNAME", shortHost: "www", name: `www.${apex}`, value: res.servingTarget },
2098
+ { type: "ALIAS", shortHost: "@", name: apex, value: res.servingTarget }
2099
+ ],
2100
+ servingInstructions: res.servingInstructions,
2101
+ retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
2102
+ maxAttempts: DNS_MAX_ATTEMPTS
1948
2103
  },
1949
2104
  "waiting_user"
1950
2105
  );
@@ -2498,7 +2653,9 @@ Project directory contract: ONE directory = ONE site (its .sakupa/site.json hold
2498
2653
  binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2499
2654
  the directory the user is currently working in, on every call. Without it the server falls
2500
2655
  back to its startup directory, which may be a different project than the one the user is
2501
- looking at. analyze_site, deploy_site, site_status, refresh_site, delete_site and unbind_domain echo
2656
+ looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2657
+ Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
2658
+ refresh_site, delete_site and unbind_domain echo
2502
2659
  the directory they acted on \u2014 verify it matches the user's active project.
2503
2660
 
2504
2661
  Safety boundaries:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakupa/mcp",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
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",