@sakupa/mcp 0.7.8 → 0.7.10

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 +243 -33
  2. package/dist/index.js +243 -33
  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.10";
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,131 @@ 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
+ var DNS_RETRY_AFTER_SECONDS = 300;
1362
+ var DNS_MAX_ATTEMPTS = 10;
1363
+ function toDnsChecklist(checks) {
1364
+ return checks.map((c) => ({
1365
+ name: c.record.name,
1366
+ type: c.record.type,
1367
+ shortHost: c.shortHost,
1368
+ state: c.state,
1369
+ ...c.fix ? { fix: c.fix } : {}
1370
+ }));
1371
+ }
1372
+ function renderChecklistBlock(diag, fixTail) {
1373
+ const cadence = `Re-check every ${DNS_RETRY_AFTER_SECONDS / 60} minutes, up to ${DNS_MAX_ATTEMPTS} times.`;
1374
+ return `Live DNS checklist (host values are the SHORT panel form):
1375
+ ${diag.checklist}
1376
+
1377
+ ${diag.layers}
1378
+
1379
+ ` + (diag.allOk ? "All records are live; certificate issuance completes automatically \u2014 re-check in a few minutes until the binding is active." : `${fixTail} ${cadence}`);
1380
+ }
1381
+
1251
1382
  // src/version.ts
1252
1383
  var MCP_VERSION = SAKUPA_MCP_VERSION;
1253
1384
  var CLIENT_TYPE = "sakupa-mcp";
@@ -1498,6 +1629,38 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1498
1629
  }
1499
1630
  return targets.length;
1500
1631
  }
1632
+ async function describePendingBinding(client, credential, pb) {
1633
+ const framing = `
1634
+ Domain binding IN PROGRESS: ${pb.apexDomain} \u2014 ` + (pb.phase === "provisioning" ? "ownership verified; certificates/serving are provisioning." : `awaiting DNS verification (challenge valid until ${pb.verificationExpiresAt}).`);
1635
+ let check;
1636
+ try {
1637
+ check = await client.checkVerification(pb.verificationId, credential);
1638
+ } catch {
1639
+ return {
1640
+ note: framing + "\n(Couldn't refresh binding progress from the server just now \u2014 try site_status again shortly.)"
1641
+ };
1642
+ }
1643
+ try {
1644
+ const diag = await diagnoseBinding({
1645
+ apexDomain: check.apexDomain,
1646
+ servingTarget: check.servingTarget,
1647
+ ...check.verificationRecord ? { verificationRecord: check.verificationRecord } : {},
1648
+ pendingDnsRecords: check.pendingDnsRecords,
1649
+ verificationStatus: check.status,
1650
+ provisioning: pb.phase === "provisioning"
1651
+ });
1652
+ const block = renderChecklistBlock(
1653
+ diag,
1654
+ "Relay every [MISSING]/[FIX] line to the user with its exact fix."
1655
+ );
1656
+ return { note: `${framing}
1657
+ ${block}`, checklist: toDnsChecklist(diag.checks) };
1658
+ } catch {
1659
+ return {
1660
+ note: framing + '\n(Live DNS lookups are unavailable right now \u2014 run bind_domain with action "status" for the per-record checklist.)'
1661
+ };
1662
+ }
1663
+ }
1501
1664
  function freeSiteCreationBarrier() {
1502
1665
  const recent = listRecentCreations(Date.now());
1503
1666
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -1628,6 +1791,7 @@ Next action: ${analysis.suggestedNextAction}`,
1628
1791
  return text(
1629
1792
  "site_published",
1630
1793
  `Site published: ${finalized2.url}
1794
+ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
1631
1795
  Project directory: ${ctx.projectDir}
1632
1796
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1633
1797
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
@@ -1647,7 +1811,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1647
1811
  totalBytes: finalized2.totalBytes,
1648
1812
  warnings: finalized2.warnings,
1649
1813
  credentialStoredLocally: true,
1650
- projectDir: ctx.projectDir
1814
+ projectDir: ctx.projectDir,
1815
+ environment: environmentFor(ctx.apiBaseUrl)
1651
1816
  }
1652
1817
  );
1653
1818
  }
@@ -1693,6 +1858,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1693
1858
  return text(
1694
1859
  "site_updated",
1695
1860
  `Site updated: ${finalized.url}
1861
+ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
1696
1862
  Project directory: ${ctx.projectDir}
1697
1863
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1698
1864
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
@@ -1706,6 +1872,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1706
1872
  url: finalized.url,
1707
1873
  mode: finalized.mode,
1708
1874
  projectDir: ctx.projectDir,
1875
+ environment: environmentFor(ctx.apiBaseUrl),
1709
1876
  expiresAt: finalized.expiresAt,
1710
1877
  filesUploaded: uploaded,
1711
1878
  totalBytes: finalized.totalBytes,
@@ -1754,9 +1921,11 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
1754
1921
  const ctx = withProjectDir(baseCtx, args.projectDir);
1755
1922
  const site = requireSiteFile(ctx);
1756
1923
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
1757
- return textJson("site_status_returned", "Site status:", {
1924
+ const binding = res.pendingDomainBinding ? await describePendingBinding(ctx.client, site.credential, res.pendingDomainBinding) : void 0;
1925
+ return textJson("site_status_returned", `Site status:${binding?.note ?? ""}`, {
1758
1926
  ...res,
1759
- projectDir: ctx.projectDir
1927
+ projectDir: ctx.projectDir,
1928
+ ...binding?.checklist ? { dnsChecklist: binding.checklist } : {}
1760
1929
  });
1761
1930
  } catch (e) {
1762
1931
  return toolError(e);
@@ -1820,7 +1989,9 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1820
1989
  projectDir: projectDirInput,
1821
1990
  action: z3.enum(["start", "status"]),
1822
1991
  hostname: z3.string().optional().describe("Required for start."),
1823
- verificationId: z3.string().optional().describe("Required for status.")
1992
+ verificationId: z3.string().optional().describe(
1993
+ "Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
1994
+ )
1824
1995
  }
1825
1996
  },
1826
1997
  async (args) => {
@@ -1828,30 +1999,42 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1828
1999
  const ctx = withProjectDir(baseCtx, args.projectDir);
1829
2000
  const site = requireSiteFile(ctx);
1830
2001
  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);
2002
+ const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
1835
2003
  if (res2.status === "verified") {
1836
2004
  writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
1837
2005
  }
2006
+ const apex2 = res2.apexDomain;
2007
+ const diag = await diagnoseBinding({
2008
+ apexDomain: apex2,
2009
+ servingTarget: res2.servingTarget,
2010
+ ...res2.verificationRecord ? { verificationRecord: res2.verificationRecord } : {},
2011
+ pendingDnsRecords: res2.pendingDnsRecords,
2012
+ verificationStatus: res2.status,
2013
+ provisioning: res2.provisioningJobId !== void 0
2014
+ });
2015
+ const { apexResolves, allOk } = diag;
1838
2016
  return text(
1839
2017
  res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
1840
- `DNS verification ${res2.verificationId}: ${res2.status}
2018
+ `Domain binding status for ${apex2}: ${res2.status}
1841
2019
  ${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)}` : ""),
2020
+
2021
+ ` + renderChecklistBlock(
2022
+ diag,
2023
+ "Fix any [MISSING]/[FIX] lines above, then re-run bind_domain status; if still failing after the attempts below, show the user this checklist."
2024
+ ),
1846
2025
  {
1847
2026
  verificationId: res2.verificationId,
1848
2027
  status: res2.status,
1849
- apexDomain: res2.apexDomain,
2028
+ apexDomain: apex2,
1850
2029
  provisioningJobId: res2.provisioningJobId,
1851
- pendingDnsRecords: res2.pendingDnsRecords,
2030
+ servingTarget: res2.servingTarget,
2031
+ dnsChecklist: toDnsChecklist(diag.checks),
2032
+ apexResolves,
2033
+ retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
2034
+ maxAttempts: DNS_MAX_ATTEMPTS,
1852
2035
  message: res2.message
1853
2036
  },
1854
- res2.status === "verified" ? "completed" : "pending_provider"
2037
+ res2.status === "verified" ? "pending_provider" : "waiting_user"
1855
2038
  );
1856
2039
  }
1857
2040
  if (!args.hostname) {
@@ -1862,25 +2045,42 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
1862
2045
  hostname: args.hostname
1863
2046
  };
1864
2047
  const res = await ctx.client.bindDomain(site.credential, req);
2048
+ const apex = res.apexDomain;
2049
+ const txtShort = shortHostFor(res.verificationRecord.name, apex);
1865
2050
  return text(
1866
2051
  "domain_verification_started",
1867
- `Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
2052
+ `Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
1868
2053
 
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.
2054
+ Add ALL THREE DNS records NOW (adding them together lets verification, certificate issuance and serving complete without further record changes):
1874
2055
 
1875
- 2. Serving DNS (after verification): ${res.servingInstructions}
2056
+ 1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
2057
+ 2) CNAME host: www value: ${res.servingTarget}
2058
+ 3) APEX host: @ -> ${res.servingTarget} via your DNS panel's ALIAS / ANAME / CNAME-flattening feature (an apex cannot use a plain CNAME).
1876
2059
 
1877
- Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`,
2060
+ 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.
2061
+
2062
+ Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
2063
+
2064
+ 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
2065
  {
1879
2066
  verificationId: res.verificationId,
1880
- apexDomain: res.apexDomain,
2067
+ apexDomain: apex,
1881
2068
  includedHostnames: res.includedHostnames,
1882
2069
  verificationRecord: res.verificationRecord,
1883
- servingInstructions: res.servingInstructions
2070
+ servingTarget: res.servingTarget,
2071
+ requiredRecords: [
2072
+ {
2073
+ type: "TXT",
2074
+ shortHost: txtShort,
2075
+ name: res.verificationRecord.name,
2076
+ value: res.verificationRecord.value
2077
+ },
2078
+ { type: "CNAME", shortHost: "www", name: `www.${apex}`, value: res.servingTarget },
2079
+ { type: "ALIAS", shortHost: "@", name: apex, value: res.servingTarget }
2080
+ ],
2081
+ servingInstructions: res.servingInstructions,
2082
+ retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
2083
+ maxAttempts: DNS_MAX_ATTEMPTS
1884
2084
  },
1885
2085
  "waiting_user"
1886
2086
  );
@@ -2136,6 +2336,12 @@ ${res.archiveUrl}`,
2136
2336
  deploymentId: z3.string().optional(),
2137
2337
  severity: severityEnum.optional(),
2138
2338
  description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
2339
+ agentContext: z3.string().optional().describe(
2340
+ "YOUR OWN factual account of the session as the AI: which tools you called, what they returned, expected vs actual. Write it yourself from your observations \u2014 never ask the user to compose it, and do not read it back to them; it travels alongside the user's description as a second witness. No secrets, no file contents."
2341
+ ),
2342
+ contactEmail: z3.string().optional().describe(
2343
+ "OPTIONAL. Before submitting, ask the user ONCE whether they want to leave a contact for follow-up. Omit entirely if they decline \u2014 never require it."
2344
+ ),
2139
2345
  confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
2140
2346
  }
2141
2347
  },
@@ -2160,7 +2366,9 @@ ${res.archiveUrl}`,
2160
2366
  ...site ? { siteId: site.siteId } : {},
2161
2367
  ...args.severity !== void 0 ? { severity: args.severity } : {},
2162
2368
  diagnostics,
2163
- ...args.description !== void 0 ? { description: args.description } : {}
2369
+ ...args.description !== void 0 ? { description: args.description } : {},
2370
+ ...args.agentContext !== void 0 ? { agentContext: args.agentContext } : {},
2371
+ ...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
2164
2372
  };
2165
2373
  if (args.confirmSubmit !== true) {
2166
2374
  return textJson(
@@ -2503,7 +2711,9 @@ Project directory contract: ONE directory = ONE site (its .sakupa/site.json hold
2503
2711
  binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2504
2712
  the directory the user is currently working in, on every call. Without it the server falls
2505
2713
  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
2714
+ looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2715
+ Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
2716
+ refresh_site, delete_site and unbind_domain echo
2507
2717
  the directory they acted on \u2014 verify it matches the user's active project.
2508
2718
 
2509
2719
  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.10";
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,131 @@ 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
+ var DNS_RETRY_AFTER_SECONDS = 300;
1570
+ var DNS_MAX_ATTEMPTS = 10;
1571
+ function toDnsChecklist(checks) {
1572
+ return checks.map((c) => ({
1573
+ name: c.record.name,
1574
+ type: c.record.type,
1575
+ shortHost: c.shortHost,
1576
+ state: c.state,
1577
+ ...c.fix ? { fix: c.fix } : {}
1578
+ }));
1579
+ }
1580
+ function renderChecklistBlock(diag, fixTail) {
1581
+ const cadence = `Re-check every ${DNS_RETRY_AFTER_SECONDS / 60} minutes, up to ${DNS_MAX_ATTEMPTS} times.`;
1582
+ return `Live DNS checklist (host values are the SHORT panel form):
1583
+ ${diag.checklist}
1584
+
1585
+ ${diag.layers}
1586
+
1587
+ ` + (diag.allOk ? "All records are live; certificate issuance completes automatically \u2014 re-check in a few minutes until the binding is active." : `${fixTail} ${cadence}`);
1588
+ }
1589
+
1459
1590
  // src/tools/definitions.ts
1460
1591
  function text(resultCode, t, data = {}, outcome = "completed") {
1461
1592
  return structuredToolResult({
@@ -1562,6 +1693,38 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1562
1693
  }
1563
1694
  return targets.length;
1564
1695
  }
1696
+ async function describePendingBinding(client, credential, pb) {
1697
+ const framing = `
1698
+ Domain binding IN PROGRESS: ${pb.apexDomain} \u2014 ` + (pb.phase === "provisioning" ? "ownership verified; certificates/serving are provisioning." : `awaiting DNS verification (challenge valid until ${pb.verificationExpiresAt}).`);
1699
+ let check;
1700
+ try {
1701
+ check = await client.checkVerification(pb.verificationId, credential);
1702
+ } catch {
1703
+ return {
1704
+ note: framing + "\n(Couldn't refresh binding progress from the server just now \u2014 try site_status again shortly.)"
1705
+ };
1706
+ }
1707
+ try {
1708
+ const diag = await diagnoseBinding({
1709
+ apexDomain: check.apexDomain,
1710
+ servingTarget: check.servingTarget,
1711
+ ...check.verificationRecord ? { verificationRecord: check.verificationRecord } : {},
1712
+ pendingDnsRecords: check.pendingDnsRecords,
1713
+ verificationStatus: check.status,
1714
+ provisioning: pb.phase === "provisioning"
1715
+ });
1716
+ const block = renderChecklistBlock(
1717
+ diag,
1718
+ "Relay every [MISSING]/[FIX] line to the user with its exact fix."
1719
+ );
1720
+ return { note: `${framing}
1721
+ ${block}`, checklist: toDnsChecklist(diag.checks) };
1722
+ } catch {
1723
+ return {
1724
+ note: framing + '\n(Live DNS lookups are unavailable right now \u2014 run bind_domain with action "status" for the per-record checklist.)'
1725
+ };
1726
+ }
1727
+ }
1565
1728
  function freeSiteCreationBarrier() {
1566
1729
  const recent = listRecentCreations(Date.now());
1567
1730
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -1692,6 +1855,7 @@ Next action: ${analysis.suggestedNextAction}`,
1692
1855
  return text(
1693
1856
  "site_published",
1694
1857
  `Site published: ${finalized2.url}
1858
+ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
1695
1859
  Project directory: ${ctx.projectDir}
1696
1860
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1697
1861
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
@@ -1711,7 +1875,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1711
1875
  totalBytes: finalized2.totalBytes,
1712
1876
  warnings: finalized2.warnings,
1713
1877
  credentialStoredLocally: true,
1714
- projectDir: ctx.projectDir
1878
+ projectDir: ctx.projectDir,
1879
+ environment: environmentFor(ctx.apiBaseUrl)
1715
1880
  }
1716
1881
  );
1717
1882
  }
@@ -1757,6 +1922,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1757
1922
  return text(
1758
1923
  "site_updated",
1759
1924
  `Site updated: ${finalized.url}
1925
+ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
1760
1926
  Project directory: ${ctx.projectDir}
1761
1927
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1762
1928
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
@@ -1770,6 +1936,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1770
1936
  url: finalized.url,
1771
1937
  mode: finalized.mode,
1772
1938
  projectDir: ctx.projectDir,
1939
+ environment: environmentFor(ctx.apiBaseUrl),
1773
1940
  expiresAt: finalized.expiresAt,
1774
1941
  filesUploaded: uploaded,
1775
1942
  totalBytes: finalized.totalBytes,
@@ -1818,9 +1985,11 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
1818
1985
  const ctx = withProjectDir(baseCtx, args.projectDir);
1819
1986
  const site = requireSiteFile(ctx);
1820
1987
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
1821
- return textJson("site_status_returned", "Site status:", {
1988
+ const binding = res.pendingDomainBinding ? await describePendingBinding(ctx.client, site.credential, res.pendingDomainBinding) : void 0;
1989
+ return textJson("site_status_returned", `Site status:${binding?.note ?? ""}`, {
1822
1990
  ...res,
1823
- projectDir: ctx.projectDir
1991
+ projectDir: ctx.projectDir,
1992
+ ...binding?.checklist ? { dnsChecklist: binding.checklist } : {}
1824
1993
  });
1825
1994
  } catch (e) {
1826
1995
  return toolError(e);
@@ -1884,7 +2053,9 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1884
2053
  projectDir: projectDirInput,
1885
2054
  action: z3.enum(["start", "status"]),
1886
2055
  hostname: z3.string().optional().describe("Required for start."),
1887
- verificationId: z3.string().optional().describe("Required for status.")
2056
+ verificationId: z3.string().optional().describe(
2057
+ "Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
2058
+ )
1888
2059
  }
1889
2060
  },
1890
2061
  async (args) => {
@@ -1892,30 +2063,42 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1892
2063
  const ctx = withProjectDir(baseCtx, args.projectDir);
1893
2064
  const site = requireSiteFile(ctx);
1894
2065
  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);
2066
+ const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
1899
2067
  if (res2.status === "verified") {
1900
2068
  writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
1901
2069
  }
2070
+ const apex2 = res2.apexDomain;
2071
+ const diag = await diagnoseBinding({
2072
+ apexDomain: apex2,
2073
+ servingTarget: res2.servingTarget,
2074
+ ...res2.verificationRecord ? { verificationRecord: res2.verificationRecord } : {},
2075
+ pendingDnsRecords: res2.pendingDnsRecords,
2076
+ verificationStatus: res2.status,
2077
+ provisioning: res2.provisioningJobId !== void 0
2078
+ });
2079
+ const { apexResolves, allOk } = diag;
1902
2080
  return text(
1903
2081
  res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
1904
- `DNS verification ${res2.verificationId}: ${res2.status}
2082
+ `Domain binding status for ${apex2}: ${res2.status}
1905
2083
  ${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)}` : ""),
2084
+
2085
+ ` + renderChecklistBlock(
2086
+ diag,
2087
+ "Fix any [MISSING]/[FIX] lines above, then re-run bind_domain status; if still failing after the attempts below, show the user this checklist."
2088
+ ),
1910
2089
  {
1911
2090
  verificationId: res2.verificationId,
1912
2091
  status: res2.status,
1913
- apexDomain: res2.apexDomain,
2092
+ apexDomain: apex2,
1914
2093
  provisioningJobId: res2.provisioningJobId,
1915
- pendingDnsRecords: res2.pendingDnsRecords,
2094
+ servingTarget: res2.servingTarget,
2095
+ dnsChecklist: toDnsChecklist(diag.checks),
2096
+ apexResolves,
2097
+ retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
2098
+ maxAttempts: DNS_MAX_ATTEMPTS,
1916
2099
  message: res2.message
1917
2100
  },
1918
- res2.status === "verified" ? "completed" : "pending_provider"
2101
+ res2.status === "verified" ? "pending_provider" : "waiting_user"
1919
2102
  );
1920
2103
  }
1921
2104
  if (!args.hostname) {
@@ -1926,25 +2109,42 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
1926
2109
  hostname: args.hostname
1927
2110
  };
1928
2111
  const res = await ctx.client.bindDomain(site.credential, req);
2112
+ const apex = res.apexDomain;
2113
+ const txtShort = shortHostFor(res.verificationRecord.name, apex);
1929
2114
  return text(
1930
2115
  "domain_verification_started",
1931
- `Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
2116
+ `Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
1932
2117
 
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.
2118
+ Add ALL THREE DNS records NOW (adding them together lets verification, certificate issuance and serving complete without further record changes):
1938
2119
 
1939
- 2. Serving DNS (after verification): ${res.servingInstructions}
2120
+ 1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
2121
+ 2) CNAME host: www value: ${res.servingTarget}
2122
+ 3) APEX host: @ -> ${res.servingTarget} via your DNS panel's ALIAS / ANAME / CNAME-flattening feature (an apex cannot use a plain CNAME).
1940
2123
 
1941
- Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`,
2124
+ 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.
2125
+
2126
+ Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
2127
+
2128
+ 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
2129
  {
1943
2130
  verificationId: res.verificationId,
1944
- apexDomain: res.apexDomain,
2131
+ apexDomain: apex,
1945
2132
  includedHostnames: res.includedHostnames,
1946
2133
  verificationRecord: res.verificationRecord,
1947
- servingInstructions: res.servingInstructions
2134
+ servingTarget: res.servingTarget,
2135
+ requiredRecords: [
2136
+ {
2137
+ type: "TXT",
2138
+ shortHost: txtShort,
2139
+ name: res.verificationRecord.name,
2140
+ value: res.verificationRecord.value
2141
+ },
2142
+ { type: "CNAME", shortHost: "www", name: `www.${apex}`, value: res.servingTarget },
2143
+ { type: "ALIAS", shortHost: "@", name: apex, value: res.servingTarget }
2144
+ ],
2145
+ servingInstructions: res.servingInstructions,
2146
+ retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
2147
+ maxAttempts: DNS_MAX_ATTEMPTS
1948
2148
  },
1949
2149
  "waiting_user"
1950
2150
  );
@@ -2200,6 +2400,12 @@ ${res.archiveUrl}`,
2200
2400
  deploymentId: z3.string().optional(),
2201
2401
  severity: severityEnum.optional(),
2202
2402
  description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
2403
+ agentContext: z3.string().optional().describe(
2404
+ "YOUR OWN factual account of the session as the AI: which tools you called, what they returned, expected vs actual. Write it yourself from your observations \u2014 never ask the user to compose it, and do not read it back to them; it travels alongside the user's description as a second witness. No secrets, no file contents."
2405
+ ),
2406
+ contactEmail: z3.string().optional().describe(
2407
+ "OPTIONAL. Before submitting, ask the user ONCE whether they want to leave a contact for follow-up. Omit entirely if they decline \u2014 never require it."
2408
+ ),
2203
2409
  confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
2204
2410
  }
2205
2411
  },
@@ -2224,7 +2430,9 @@ ${res.archiveUrl}`,
2224
2430
  ...site ? { siteId: site.siteId } : {},
2225
2431
  ...args.severity !== void 0 ? { severity: args.severity } : {},
2226
2432
  diagnostics,
2227
- ...args.description !== void 0 ? { description: args.description } : {}
2433
+ ...args.description !== void 0 ? { description: args.description } : {},
2434
+ ...args.agentContext !== void 0 ? { agentContext: args.agentContext } : {},
2435
+ ...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
2228
2436
  };
2229
2437
  if (args.confirmSubmit !== true) {
2230
2438
  return textJson(
@@ -2498,7 +2706,9 @@ Project directory contract: ONE directory = ONE site (its .sakupa/site.json hold
2498
2706
  binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2499
2707
  the directory the user is currently working in, on every call. Without it the server falls
2500
2708
  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
2709
+ looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2710
+ Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
2711
+ refresh_site, delete_site and unbind_domain echo
2502
2712
  the directory they acted on \u2014 verify it matches the user's active project.
2503
2713
 
2504
2714
  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.10",
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",