@sakupa/mcp 0.7.7 → 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 +429 -112
  2. package/dist/index.js +430 -111
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
5
5
  var TEST_ACCESS_HEADER = "x-sakupa-test-token";
6
6
  var FREE_SITE_TTL_HOURS = 24;
7
7
  var FREE_SITE_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
8
+ var FREE_ACTIVE_SITES_PER_IP = 3;
8
9
  var PAID_SITE_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
9
10
  var MAX_FILE_COUNT = 5e3;
10
11
  var MAX_SINGLE_FILE_BYTES = 25 * 1024 * 1024;
@@ -123,7 +124,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
123
124
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
124
125
 
125
126
  // ../core/dist/domain/version.js
126
- var SAKUPA_MCP_VERSION = "0.7.7";
127
+ var SAKUPA_MCP_VERSION = "0.7.9";
127
128
 
128
129
  // ../core/dist/domain/errors.js
129
130
  var HTTP_STATUS = {
@@ -456,10 +457,10 @@ var CLIENT_TYPE = "sakupa-mcp";
456
457
  // src/config.ts
457
458
  var TEST_API_BASE_URL = "https://api-test.sakupa.com";
458
459
  function previewHostPatternFor(apiBaseUrl) {
459
- 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";
460
461
  }
461
462
  function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
462
- 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(/\/+$/, "");
463
464
  const projectDir = env["SAKUPA_PROJECT_DIR"] ?? cwd;
464
465
  const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
465
466
  if (apiBaseUrl === TEST_API_BASE_URL) {
@@ -477,6 +478,9 @@ function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
477
478
  }
478
479
  return { apiBaseUrl, projectDir };
479
480
  }
481
+ function environmentFor(apiBaseUrl) {
482
+ return apiBaseUrl === TEST_API_BASE_URL ? "test" : "production";
483
+ }
480
484
 
481
485
  // src/transport.ts
482
486
  var FetchTransport = class {
@@ -658,11 +662,14 @@ var HttpApiClient = class {
658
662
  async bindDomain(credential, req) {
659
663
  return this.call("POST", "/v1/domains/bind", { credential, body: req });
660
664
  }
661
- async checkVerification(verificationId, credential) {
665
+ async checkVerification(verificationId, credential, latestForSiteId) {
662
666
  return this.call(
663
667
  "POST",
664
668
  `/v1/domains/verifications/${encodeURIComponent(verificationId)}/check`,
665
- { credential }
669
+ {
670
+ credential,
671
+ ...latestForSiteId !== void 0 ? { body: { siteId: latestForSiteId } } : {}
672
+ }
666
673
  );
667
674
  }
668
675
  async unbindDomain(siteId, credential, req) {
@@ -1265,6 +1272,12 @@ async function analyzeProject(projectDir, opts = {}) {
1265
1272
  };
1266
1273
  }
1267
1274
 
1275
+ // src/tools/context.ts
1276
+ import { z as z2 } from "zod";
1277
+ import { statSync } from "node:fs";
1278
+ import { homedir } from "node:os";
1279
+ import { isAbsolute, parse, resolve as resolve2 } from "node:path";
1280
+
1268
1281
  // src/tools/result.ts
1269
1282
  import { z } from "zod";
1270
1283
  var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
@@ -1307,22 +1320,64 @@ function structuredToolResult(envelope) {
1307
1320
  }
1308
1321
 
1309
1322
  // src/tools/context.ts
1323
+ var LocalGuidanceError = class extends SakupaError {
1324
+ constructor(code, message) {
1325
+ super(code, message);
1326
+ }
1327
+ };
1328
+ var projectDirInput = z2.string().optional().describe(
1329
+ "Absolute path of the project directory the user is CURRENTLY working in (the folder holding the site files and .sakupa). Always pass it explicitly; when omitted the server falls back to its startup directory, which may not be where the user is working now."
1330
+ );
1331
+ function withProjectDir(ctx, projectDirArg) {
1332
+ if (projectDirArg === void 0) return ctx;
1333
+ if (!isAbsolute(projectDirArg)) {
1334
+ throw new LocalGuidanceError(
1335
+ "invalid_request",
1336
+ `projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
1337
+ );
1338
+ }
1339
+ const dir = resolve2(projectDirArg);
1340
+ if (parse(dir).root === dir || dir === homedir()) {
1341
+ throw new LocalGuidanceError(
1342
+ "invalid_request",
1343
+ `projectDir "${dir}" is a filesystem root or the home directory. Pass the specific project folder that holds the site's files, not a top-level directory.`
1344
+ );
1345
+ }
1346
+ const stat = statSync(dir, { throwIfNoEntry: false });
1347
+ if (!stat?.isDirectory()) {
1348
+ throw new LocalGuidanceError(
1349
+ "invalid_request",
1350
+ `projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
1351
+ );
1352
+ }
1353
+ return { ...ctx, projectDir: dir };
1354
+ }
1310
1355
  function requireSiteFile(ctx) {
1311
1356
  const state = loadSiteFile(ctx.projectDir);
1312
1357
  if (state.kind === "corrupted") {
1313
- throw new SakupaError(
1358
+ throw new LocalGuidanceError(
1314
1359
  "invalid_request",
1315
1360
  `.sakupa/site.json in ${ctx.projectDir} is damaged: ${state.problem}. ` + siteFileRecoveryGuidance(ctx.projectDir)
1316
1361
  );
1317
1362
  }
1318
1363
  if (state.kind === "absent") {
1319
- throw new SakupaError(
1364
+ throw new LocalGuidanceError(
1320
1365
  "not_found",
1321
1366
  `No .sakupa/site.json found in ${ctx.projectDir}. This project has no Sakupa site binding yet \u2014 run deploy_site first to publish it (the management credential will be stored locally in .sakupa/site.json). If this was a paid custom-domain site whose project file was lost, use recover_domain_site instead.`
1322
1367
  );
1323
1368
  }
1324
1369
  return state.file;
1325
1370
  }
1371
+ var STATIC_SUMMARY = {
1372
+ not_found: "The required local project binding or resource is unavailable; if this project has no .sakupa/site.json yet, run deploy_site first.",
1373
+ unauthorized: "The server rejected the site credential: the one in .sakupa/site.json no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site.",
1374
+ invalid_request: "The request arguments or local project checks did not pass.",
1375
+ validation_failed: "The request arguments or local project checks did not pass.",
1376
+ state_conflict: "The resource state has changed; re-query the current status before deciding the next step.",
1377
+ confirmation_required: "The site or billing state changed, so the previous confirmation is stale; run the preview again and confirm against the fresh snapshot.",
1378
+ payment_required: "This operation requires an active subscription; check billing_status first.",
1379
+ rate_limited: "The server rate limit was reached; retry after the returned wait time."
1380
+ };
1326
1381
  function toolError(e) {
1327
1382
  const errorCode = isSakupaError(e) ? e.code : "internal";
1328
1383
  const retryable = errorCode === "rate_limited" || errorCode === "internal";
@@ -1341,7 +1396,7 @@ function toolError(e) {
1341
1396
  )
1342
1397
  ) : void 0;
1343
1398
  const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
1344
- const safeSummary = errorCode === "upgrade_required" ? `This Sakupa MCP client is v${MCP_VERSION}, older than the server's minimum supported version${minimumVersion !== void 0 ? ` (v${minimumVersion})` : ""}, so the server refused the call. To fix it: ask the user to fully restart their MCP client session \u2014 "npx -y @sakupa/mcp@latest" setups fetch the current version on restart (run "npx clear-npx-cache" first if the old version persists); global installs need "npm install -g @sakupa/mcp@latest". After the restart, retry this exact tool call.` : errorCode === "not_found" ? "The required local project binding or resource is unavailable; if this project has no .sakupa/site.json yet, run deploy_site first." : errorCode === "unauthorized" ? "The server rejected the site credential: the one in .sakupa/site.json no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site." : errorCode === "invalid_request" || errorCode === "validation_failed" ? "The request arguments or local project checks did not pass." : errorCode === "state_conflict" ? "The resource state has changed; re-query the current status before deciding the next step." : errorCode === "confirmation_required" ? "The site or billing state changed, so the previous confirmation is stale; run the preview again and confirm against the fresh snapshot." : errorCode === "payment_required" ? "This operation requires an active subscription; check billing_status first." : errorCode === "rate_limited" ? "The server rate limit was reached; retry after the returned wait time." : retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : "The operation failed; no server-internal details are exposed.";
1399
+ const safeSummary = e instanceof LocalGuidanceError ? e.message : errorCode === "upgrade_required" ? `This Sakupa MCP client is v${MCP_VERSION}, older than the server's minimum supported version${minimumVersion !== void 0 ? ` (v${minimumVersion})` : ""}, so the server refused the call. To fix it: ask the user to fully restart their MCP client session \u2014 "npx -y @sakupa/mcp@latest" setups fetch the current version on restart (run "npx clear-npx-cache" first if the old version persists); global installs need "npm install -g @sakupa/mcp@latest". After the restart, retry this exact tool call.` : STATIC_SUMMARY[errorCode] ?? (retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : "The operation failed; no server-internal details are exposed.");
1345
1400
  const result = structuredToolResult({
1346
1401
  schemaVersion: 1,
1347
1402
  outcome: "failed",
@@ -1360,8 +1415,159 @@ function toolError(e) {
1360
1415
  // src/tools/definitions.ts
1361
1416
  import { randomUUID } from "node:crypto";
1362
1417
  import { promises as fs2 } from "node:fs";
1363
- import { join as join3, resolve as resolve2 } from "node:path";
1364
- import { z as z2 } from "zod";
1418
+ import { join as join4, resolve as resolve3 } from "node:path";
1419
+ import { z as z3 } from "zod";
1420
+
1421
+ // src/creation-registry.ts
1422
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1423
+ import { homedir as homedir2 } from "node:os";
1424
+ import { dirname as dirname2, join as join3 } from "node:path";
1425
+ var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
1426
+ function creationRegistryPath() {
1427
+ const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
1428
+ return join3(base, ".sakupa", "created-sites.json");
1429
+ }
1430
+ function readAll() {
1431
+ const path = creationRegistryPath();
1432
+ if (!existsSync2(path)) return [];
1433
+ try {
1434
+ const parsed = JSON.parse(readFileSync2(path, "utf-8"));
1435
+ if (!Array.isArray(parsed)) return [];
1436
+ return parsed.filter(
1437
+ (e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
1438
+ );
1439
+ } catch {
1440
+ return [];
1441
+ }
1442
+ }
1443
+ function writeAll(records) {
1444
+ const path = creationRegistryPath();
1445
+ mkdirSync2(dirname2(path), { recursive: true });
1446
+ writeFileSync2(path, `${JSON.stringify(records, null, 2)}
1447
+ `, "utf-8");
1448
+ }
1449
+ function listRecentCreations(nowMs) {
1450
+ return readAll().filter((e) => {
1451
+ const t = Date.parse(e.createdAt);
1452
+ return Number.isFinite(t) && nowMs - t < RECENT_WINDOW_MS;
1453
+ });
1454
+ }
1455
+ function recordCreation(record) {
1456
+ const rest = readAll().filter((e) => e.siteId !== record.siteId);
1457
+ writeAll([...rest, record]);
1458
+ }
1459
+ function removeCreation(siteId) {
1460
+ const all = readAll();
1461
+ const rest = all.filter((e) => e.siteId !== siteId);
1462
+ if (rest.length !== all.length) writeAll(rest);
1463
+ }
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
+
1570
+ // src/tools/definitions.ts
1365
1571
  function text(resultCode, t, data = {}, outcome = "completed") {
1366
1572
  return structuredToolResult({
1367
1573
  schemaVersion: 1,
@@ -1384,12 +1590,12 @@ ${JSON.stringify(obj, null, 2)}`;
1384
1590
  nextActions: []
1385
1591
  });
1386
1592
  }
1387
- var planEnum = z2.enum(["water", "personal", "share", "business"]);
1388
- var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
1593
+ var planEnum = z3.enum(["water", "personal", "share", "business"]);
1594
+ var severityEnum = z3.enum(["low", "medium", "high", "critical"]);
1389
1595
  function planCatalog() {
1390
1596
  return TIER_ORDER.map((p) => `${p} JPY ${tierPriceJpy(p)}/month`).join(", ");
1391
1597
  }
1392
- var ticketCategoryEnum = z2.enum([
1598
+ var ticketCategoryEnum = z3.enum([
1393
1599
  "billing",
1394
1600
  "payment",
1395
1601
  "refund_review",
@@ -1437,7 +1643,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
1437
1643
  async function buildHashedManifest(files, outputAbs) {
1438
1644
  const manifest = [];
1439
1645
  for (const file of files) {
1440
- const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, file.path)));
1646
+ const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, file.path)));
1441
1647
  manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
1442
1648
  }
1443
1649
  return manifest;
@@ -1456,7 +1662,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1456
1662
  `No local file matches upload target "${target.path}"; aborting upload.`
1457
1663
  );
1458
1664
  }
1459
- const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, match.path)));
1665
+ const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, match.path)));
1460
1666
  if (bytes.byteLength !== match.size) {
1461
1667
  throw new SakupaError(
1462
1668
  "validation_failed",
@@ -1467,8 +1673,25 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1467
1673
  }
1468
1674
  return targets.length;
1469
1675
  }
1470
- function registerTools(server, ctx) {
1471
- const previewHostPattern = previewHostPatternFor(ctx.apiBaseUrl);
1676
+ var DNS_RETRY_AFTER_SECONDS = 300;
1677
+ var DNS_MAX_ATTEMPTS = 10;
1678
+ function freeSiteCreationBarrier() {
1679
+ const recent = listRecentCreations(Date.now());
1680
+ if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
1681
+ const registryPath = creationRegistryPath();
1682
+ return text(
1683
+ "local_site_limit_reached",
1684
+ `This machine already created ${recent.length} free sites in the last 24 hours (the server also enforces ${FREE_ACTIVE_SITES_PER_IP} active free sites per IP). No new site was created.
1685
+
1686
+ ` + recent.map((r) => `- ${r.url} (project: ${r.projectDir}, created: ${r.createdAt})`).join("\n") + `
1687
+
1688
+ Options: delete one of these sites (run delete_site with its projectDir), wait for a free site to expire, or \u2014 if this list is stale because sites were deleted or subscribed elsewhere \u2014 remove the local registry file at ${registryPath} and retry.`,
1689
+ { recentCreations: recent, limit: FREE_ACTIVE_SITES_PER_IP, registryPath },
1690
+ "blocked"
1691
+ );
1692
+ }
1693
+ function registerTools(server, baseCtx) {
1694
+ const previewHostPattern = previewHostPatternFor(baseCtx.apiBaseUrl);
1472
1695
  server.registerTool(
1473
1696
  "analyze_site",
1474
1697
  {
@@ -1476,11 +1699,13 @@ function registerTools(server, ctx) {
1476
1699
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1477
1700
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1478
1701
  inputSchema: {
1479
- outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection).")
1702
+ projectDir: projectDirInput,
1703
+ outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection).")
1480
1704
  }
1481
1705
  },
1482
1706
  async (args) => {
1483
1707
  try {
1708
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1484
1709
  const analysis = await analyzeProject(ctx.projectDir, {
1485
1710
  ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
1486
1711
  });
@@ -1502,18 +1727,20 @@ Next action: ${analysis.suggestedNextAction}`,
1502
1727
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1503
1728
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1504
1729
  inputSchema: {
1505
- outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1506
- spaFallback: z2.boolean().optional().describe(
1730
+ projectDir: projectDirInput,
1731
+ outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1732
+ spaFallback: z3.boolean().optional().describe(
1507
1733
  "Override automatic SPA-fallback detection (single index.html + JS auto-enables rewriting unknown paths to index.html; multiple HTML pages auto-disable it). Pass only to force the behavior against the detected structure."
1508
1734
  ),
1509
- publicConfirmed: z2.boolean().optional().describe(
1735
+ publicConfirmed: z3.boolean().optional().describe(
1510
1736
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1511
1737
  ),
1512
- lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1738
+ lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1513
1739
  }
1514
1740
  },
1515
1741
  async (args) => {
1516
1742
  try {
1743
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1517
1744
  const analysis = await analyzeProject(ctx.projectDir, {
1518
1745
  ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
1519
1746
  });
@@ -1521,7 +1748,7 @@ Next action: ${analysis.suggestedNextAction}`,
1521
1748
  return notDeployableResult(analysis);
1522
1749
  }
1523
1750
  const files = analysis.files;
1524
- const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1751
+ const outputAbs = resolve3(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1525
1752
  const manifest = await buildHashedManifest(files, outputAbs);
1526
1753
  const siteFileState = loadSiteFile(ctx.projectDir);
1527
1754
  if (siteFileState.kind === "corrupted") {
@@ -1535,6 +1762,10 @@ Next action: ${analysis.suggestedNextAction}`,
1535
1762
  );
1536
1763
  }
1537
1764
  const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
1765
+ if (!existing) {
1766
+ const barrier = freeSiteCreationBarrier();
1767
+ if (barrier) return barrier;
1768
+ }
1538
1769
  if (!existing && args.publicConfirmed !== true) {
1539
1770
  return text(
1540
1771
  "public_deployment_confirmation_required",
@@ -1556,17 +1787,26 @@ Next action: ${analysis.suggestedNextAction}`,
1556
1787
  created.siteId,
1557
1788
  created.credential
1558
1789
  );
1790
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1559
1791
  writeSiteFile(ctx.projectDir, {
1560
1792
  siteId: created.siteId,
1561
1793
  shortId: created.shortId,
1562
1794
  url: finalized2.url,
1563
1795
  credential: created.credential,
1564
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1796
+ createdAt,
1565
1797
  apiBaseUrl: ctx.apiBaseUrl
1566
1798
  });
1799
+ recordCreation({
1800
+ siteId: created.siteId,
1801
+ projectDir: ctx.projectDir,
1802
+ url: finalized2.url,
1803
+ createdAt
1804
+ });
1567
1805
  return text(
1568
1806
  "site_published",
1569
1807
  `Site published: ${finalized2.url}
1808
+ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
1809
+ Project directory: ${ctx.projectDir}
1570
1810
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1571
1811
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
1572
1812
  ` : "") + `
@@ -1584,7 +1824,9 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1584
1824
  filesUploaded: uploaded2,
1585
1825
  totalBytes: finalized2.totalBytes,
1586
1826
  warnings: finalized2.warnings,
1587
- credentialStoredLocally: true
1827
+ credentialStoredLocally: true,
1828
+ projectDir: ctx.projectDir,
1829
+ environment: environmentFor(ctx.apiBaseUrl)
1588
1830
  }
1589
1831
  );
1590
1832
  }
@@ -1630,6 +1872,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1630
1872
  return text(
1631
1873
  "site_updated",
1632
1874
  `Site updated: ${finalized.url}
1875
+ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
1876
+ Project directory: ${ctx.projectDir}
1633
1877
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1634
1878
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
1635
1879
  ` : "") + (finalized.mode === "free" ? `
@@ -1641,6 +1885,8 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1641
1885
  siteId: existing.siteId,
1642
1886
  url: finalized.url,
1643
1887
  mode: finalized.mode,
1888
+ projectDir: ctx.projectDir,
1889
+ environment: environmentFor(ctx.apiBaseUrl),
1644
1890
  expiresAt: finalized.expiresAt,
1645
1891
  filesUploaded: uploaded,
1646
1892
  totalBytes: finalized.totalBytes,
@@ -1658,17 +1904,18 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1658
1904
  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.",
1659
1905
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1660
1906
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1661
- inputSchema: {}
1907
+ inputSchema: { projectDir: projectDirInput }
1662
1908
  },
1663
- async () => {
1909
+ async (args) => {
1664
1910
  try {
1911
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1665
1912
  const site = requireSiteFile(ctx);
1666
1913
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
1667
1914
  return text(
1668
1915
  "site_refreshed",
1669
- `Site validity refreshed. New expiry: ${res.expiresAt}
1916
+ `Site validity refreshed (project: ${ctx.projectDir}). New expiry: ${res.expiresAt}
1670
1917
  NO content was uploaded or changed by this call \u2014 to publish new or edited files, run deploy_site. Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`,
1671
- { siteId: site.siteId, expiresAt: res.expiresAt }
1918
+ { siteId: site.siteId, expiresAt: res.expiresAt, projectDir: ctx.projectDir }
1672
1919
  );
1673
1920
  } catch (e) {
1674
1921
  return toolError(e);
@@ -1681,13 +1928,17 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
1681
1928
  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.",
1682
1929
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1683
1930
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1684
- inputSchema: {}
1931
+ inputSchema: { projectDir: projectDirInput }
1685
1932
  },
1686
- async () => {
1933
+ async (args) => {
1687
1934
  try {
1935
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1688
1936
  const site = requireSiteFile(ctx);
1689
1937
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
1690
- return textJson("site_status_returned", "Site status:", res);
1938
+ return textJson("site_status_returned", "Site status:", {
1939
+ ...res,
1940
+ projectDir: ctx.projectDir
1941
+ });
1691
1942
  } catch (e) {
1692
1943
  return toolError(e);
1693
1944
  }
@@ -1700,6 +1951,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
1700
1951
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1701
1952
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1702
1953
  inputSchema: {
1954
+ projectDir: projectDirInput,
1703
1955
  plan: planEnum.describe(
1704
1956
  "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
1705
1957
  )
@@ -1707,6 +1959,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
1707
1959
  },
1708
1960
  async (args) => {
1709
1961
  try {
1962
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1710
1963
  const site = requireSiteFile(ctx);
1711
1964
  const res = await ctx.client.createPlanCheckout(
1712
1965
  {
@@ -1745,39 +1998,62 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1745
1998
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1746
1999
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1747
2000
  inputSchema: {
1748
- action: z2.enum(["start", "status"]),
1749
- hostname: z2.string().optional().describe("Required for start."),
1750
- verificationId: z2.string().optional().describe("Required for status.")
2001
+ projectDir: projectDirInput,
2002
+ action: z3.enum(["start", "status"]),
2003
+ hostname: z3.string().optional().describe("Required for start."),
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
+ )
1751
2007
  }
1752
2008
  },
1753
2009
  async (args) => {
1754
2010
  try {
2011
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1755
2012
  const site = requireSiteFile(ctx);
1756
2013
  if (args.action === "status") {
1757
- if (!args.verificationId) {
1758
- throw new SakupaError("invalid_request", "verificationId is required for status");
1759
- }
1760
- 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);
1761
2015
  if (res2.status === "verified") {
1762
2016
  writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
1763
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
+ });
1764
2027
  return text(
1765
2028
  res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
1766
- `DNS verification ${res2.verificationId}: ${res2.status}
2029
+ `Domain binding status for ${apex2}: ${res2.status}
1767
2030
  ${res2.message}
1768
- ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
1769
- ` : "") + (res2.pendingDnsRecords.length > 0 ? `
1770
- DNS records still required:
1771
- ${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."),
1772
2038
  {
1773
2039
  verificationId: res2.verificationId,
1774
2040
  status: res2.status,
1775
- apexDomain: res2.apexDomain,
2041
+ apexDomain: apex2,
1776
2042
  provisioningJobId: res2.provisioningJobId,
1777
- 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,
1778
2054
  message: res2.message
1779
2055
  },
1780
- res2.status === "verified" ? "completed" : "pending_provider"
2056
+ res2.status === "verified" ? "pending_provider" : "waiting_user"
1781
2057
  );
1782
2058
  }
1783
2059
  if (!args.hostname) {
@@ -1788,25 +2064,42 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
1788
2064
  hostname: args.hostname
1789
2065
  };
1790
2066
  const res = await ctx.client.bindDomain(site.credential, req);
2067
+ const apex = res.apexDomain;
2068
+ const txtShort = shortHostFor(res.verificationRecord.name, apex);
1791
2069
  return text(
1792
2070
  "domain_verification_started",
1793
- `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):
2074
+
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).
1794
2078
 
1795
- 1. Prove control of ${res.apexDomain} by creating this DNS record:
1796
- name: ${res.verificationRecord.name}
1797
- type: ${res.verificationRecord.type}
1798
- value: ${res.verificationRecord.value}
1799
- 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.
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.
1800
2080
 
1801
- 2. Serving DNS (after verification): ${res.servingInstructions}
2081
+ Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
1802
2082
 
1803
- Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`,
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.`,
1804
2084
  {
1805
2085
  verificationId: res.verificationId,
1806
- apexDomain: res.apexDomain,
2086
+ apexDomain: apex,
1807
2087
  includedHostnames: res.includedHostnames,
1808
2088
  verificationRecord: res.verificationRecord,
1809
- 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
1810
2103
  },
1811
2104
  "waiting_user"
1812
2105
  );
@@ -1821,10 +2114,11 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
1821
2114
  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).",
1822
2115
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1823
2116
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1824
- inputSchema: {}
2117
+ inputSchema: { projectDir: projectDirInput }
1825
2118
  },
1826
- async () => {
2119
+ async (args) => {
1827
2120
  try {
2121
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1828
2122
  const site = requireSiteFile(ctx);
1829
2123
  const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
1830
2124
  const lines = [
@@ -1854,11 +2148,13 @@ Full status:`, res);
1854
2148
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1855
2149
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1856
2150
  inputSchema: {
1857
- scope: z2.enum(["site", "public_recovery"])
2151
+ projectDir: projectDirInput,
2152
+ scope: z3.enum(["site", "public_recovery"])
1858
2153
  }
1859
2154
  },
1860
2155
  async (args) => {
1861
2156
  try {
2157
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1862
2158
  if (args.scope === "site") {
1863
2159
  const site = requireSiteFile(ctx);
1864
2160
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
@@ -1909,14 +2205,16 @@ Full status:`, res);
1909
2205
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1910
2206
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1911
2207
  inputSchema: {
1912
- action: z2.enum(["start", "status", "complete"]),
1913
- hostname: z2.string().optional().describe("Required for start."),
1914
- verificationId: z2.string().optional().describe("Required for status or complete."),
1915
- preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
2208
+ projectDir: projectDirInput,
2209
+ action: z3.enum(["start", "status", "complete"]),
2210
+ hostname: z3.string().optional().describe("Required for start."),
2211
+ verificationId: z3.string().optional().describe("Required for status or complete."),
2212
+ preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
1916
2213
  }
1917
2214
  },
1918
2215
  async (args) => {
1919
2216
  try {
2217
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1920
2218
  if (args.action === "start") {
1921
2219
  if (!args.hostname) {
1922
2220
  throw new SakupaError("invalid_request", "hostname is required for start");
@@ -2014,14 +2312,16 @@ ${res.archiveUrl}`,
2014
2312
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2015
2313
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2016
2314
  inputSchema: {
2315
+ projectDir: projectDirInput,
2017
2316
  category: ticketCategoryEnum,
2018
- subject: z2.string().describe("Short subject line."),
2019
- description: z2.string().describe("Problem description (no secrets, no card data)."),
2020
- contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
2317
+ subject: z3.string().describe("Short subject line."),
2318
+ description: z3.string().describe("Problem description (no secrets, no card data)."),
2319
+ contactEmail: z3.string().optional().describe("Optional contact email for follow-up.")
2021
2320
  }
2022
2321
  },
2023
2322
  async (args) => {
2024
2323
  try {
2324
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2025
2325
  const site = requireSiteFile(ctx);
2026
2326
  const res = await ctx.client.createTicket(site.credential, {
2027
2327
  siteId: site.siteId,
@@ -2047,18 +2347,20 @@ ${res.archiveUrl}`,
2047
2347
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2048
2348
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2049
2349
  inputSchema: {
2050
- toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
2051
- errorCode: z2.string().optional(),
2052
- errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
2053
- requestId: z2.string().optional(),
2054
- deploymentId: z2.string().optional(),
2350
+ projectDir: projectDirInput,
2351
+ toolName: z3.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
2352
+ errorCode: z3.string().optional(),
2353
+ errorMessage: z3.string().optional().describe("Sanitized error message (no secrets)."),
2354
+ requestId: z3.string().optional(),
2355
+ deploymentId: z3.string().optional(),
2055
2356
  severity: severityEnum.optional(),
2056
- description: z2.string().optional().describe("What happened, in the user's words (no secrets)."),
2057
- confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
2357
+ description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
2358
+ confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
2058
2359
  }
2059
2360
  },
2060
2361
  async (args) => {
2061
2362
  try {
2363
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2062
2364
  const siteState = loadSiteFile(ctx.projectDir);
2063
2365
  const site = siteState.kind === "ok" ? siteState.file : null;
2064
2366
  const diagnostics = {
@@ -2102,39 +2404,40 @@ Summary: ${res.sanitizedSummary}`,
2102
2404
  }
2103
2405
 
2104
2406
  // src/tools/lifecycle.ts
2105
- import { z as z3 } from "zod";
2106
- var deleteConfirmation = z3.object({
2107
- siteId: z3.string().min(1),
2108
- expectedSiteUpdatedAt: z3.string().datetime(),
2109
- expectedStatus: z3.enum(["active", "expired", "deleted"]),
2110
- expectedMode: z3.enum(["free", "paid"]),
2111
- expectedServingMode: z3.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
2112
- expectedShortId: z3.string().optional(),
2113
- expectedSubscriptionStatus: z3.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
2114
- expectedPlan: z3.enum(["water", "personal", "share", "business"]).optional(),
2115
- expectedCancelAtPeriodEnd: z3.boolean().optional(),
2116
- expectedCurrentPeriodEnd: z3.string().datetime().optional(),
2117
- expectedLastDeploymentId: z3.string().optional(),
2118
- expectedBoundHostnames: z3.array(z3.string()),
2119
- acknowledge: z3.literal("delete_site_and_cancel_renewal")
2407
+ import { z as z4 } from "zod";
2408
+ var deleteConfirmation = z4.object({
2409
+ siteId: z4.string().min(1),
2410
+ expectedSiteUpdatedAt: z4.string().datetime(),
2411
+ expectedStatus: z4.enum(["active", "expired", "deleted"]),
2412
+ expectedMode: z4.enum(["free", "paid"]),
2413
+ expectedServingMode: z4.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
2414
+ expectedShortId: z4.string().optional(),
2415
+ expectedSubscriptionStatus: z4.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
2416
+ expectedPlan: z4.enum(["water", "personal", "share", "business"]).optional(),
2417
+ expectedCancelAtPeriodEnd: z4.boolean().optional(),
2418
+ expectedCurrentPeriodEnd: z4.string().datetime().optional(),
2419
+ expectedLastDeploymentId: z4.string().optional(),
2420
+ expectedBoundHostnames: z4.array(z4.string()),
2421
+ acknowledge: z4.literal("delete_site_and_cancel_renewal")
2120
2422
  });
2121
- var unbindConfirmation = z3.object({
2122
- siteId: z3.string().min(1),
2123
- bindingId: z3.string().min(1),
2124
- expectedBindingUpdatedAt: z3.string().datetime(),
2125
- expectedBindingStatus: z3.enum(["provisioning", "active"]),
2126
- apexDomain: z3.string().min(1),
2127
- expectedBoundHostnames: z3.array(z3.string()),
2128
- acknowledge: z3.literal("unbind_domain_and_remove_custom_hostnames")
2423
+ var unbindConfirmation = z4.object({
2424
+ siteId: z4.string().min(1),
2425
+ bindingId: z4.string().min(1),
2426
+ expectedBindingUpdatedAt: z4.string().datetime(),
2427
+ expectedBindingStatus: z4.enum(["provisioning", "active"]),
2428
+ apexDomain: z4.string().min(1),
2429
+ expectedBoundHostnames: z4.array(z4.string()),
2430
+ acknowledge: z4.literal("unbind_domain_and_remove_custom_hostnames")
2129
2431
  });
2130
- function registerLifecycleTools(server, ctx) {
2432
+ function registerLifecycleTools(server, baseCtx) {
2131
2433
  server.registerTool(
2132
2434
  "delete_site",
2133
2435
  {
2134
2436
  description: "Preview or execute deletion of this Sakupa site. Execution requires an exact server-validated confirmation bound to the current site state.",
2135
2437
  inputSchema: {
2136
- action: z3.enum(["preview", "confirm"]),
2137
- operationId: z3.string().min(1).optional(),
2438
+ projectDir: projectDirInput,
2439
+ action: z4.enum(["preview", "confirm"]),
2440
+ operationId: z4.string().min(1).optional(),
2138
2441
  confirmation: deleteConfirmation.optional()
2139
2442
  },
2140
2443
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -2142,6 +2445,7 @@ function registerLifecycleTools(server, ctx) {
2142
2445
  },
2143
2446
  async (args) => {
2144
2447
  try {
2448
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2145
2449
  const site = requireSiteFile(ctx);
2146
2450
  if (!args.operationId) {
2147
2451
  throw new Error("operationId is required for delete_site");
@@ -2168,13 +2472,14 @@ function registerLifecycleTools(server, ctx) {
2168
2472
  confirmation: args.confirmation
2169
2473
  });
2170
2474
  deleteSiteFile(ctx.projectDir);
2475
+ removeCreation(site.siteId);
2171
2476
  return structuredToolResult({
2172
2477
  schemaVersion: 1,
2173
2478
  outcome: result.servingDeletionPending ? "pending_provider" : "completed",
2174
2479
  resultCode: "site_deleted",
2175
2480
  operationId: args.operationId,
2176
- summary: "Site deleted; the local management credential file was removed.",
2177
- data: { result },
2481
+ summary: `Site deleted; the local management credential file was removed from ${ctx.projectDir}.`,
2482
+ data: { result, projectDir: ctx.projectDir },
2178
2483
  nextActions: []
2179
2484
  });
2180
2485
  } catch (error) {
@@ -2187,8 +2492,9 @@ function registerLifecycleTools(server, ctx) {
2187
2492
  {
2188
2493
  description: "Preview or execute removal of the custom apex/www serving surface while preserving the subscription and permanent Sakupa URL.",
2189
2494
  inputSchema: {
2190
- action: z3.enum(["preview", "confirm"]),
2191
- operationId: z3.string().min(1).optional(),
2495
+ projectDir: projectDirInput,
2496
+ action: z4.enum(["preview", "confirm"]),
2497
+ operationId: z4.string().min(1).optional(),
2192
2498
  confirmation: unbindConfirmation.optional()
2193
2499
  },
2194
2500
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -2196,6 +2502,7 @@ function registerLifecycleTools(server, ctx) {
2196
2502
  },
2197
2503
  async (args) => {
2198
2504
  try {
2505
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2199
2506
  const site = requireSiteFile(ctx);
2200
2507
  if (!args.operationId) throw new Error("operationId is required for unbind_domain");
2201
2508
  if (args.action === "preview") {
@@ -2226,7 +2533,7 @@ function registerLifecycleTools(server, ctx) {
2226
2533
  outcome: result.servingDeletionPending ? "pending_provider" : "completed",
2227
2534
  resultCode: "domain_unbound",
2228
2535
  operationId: args.operationId,
2229
- summary: "Custom domain unbound; the subscription, deployed content, and permanent Sakupa URL are unchanged.",
2536
+ summary: `Custom domain unbound; the subscription, deployed content, and permanent Sakupa URL are unchanged. (project: ${ctx.projectDir})`,
2230
2537
  data: { result },
2231
2538
  nextActions: [{ tool: "site_status", allowed: true }]
2232
2539
  });
@@ -2241,19 +2548,20 @@ function registerLifecycleTools(server, ctx) {
2241
2548
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2242
2549
 
2243
2550
  // src/tools/billing.ts
2244
- import { z as z4 } from "zod";
2245
- var plan = z4.enum(["water", "personal", "share", "business"]);
2246
- function registerBillingTools(server, ctx) {
2551
+ import { z as z5 } from "zod";
2552
+ var plan = z5.enum(["water", "personal", "share", "business"]);
2553
+ function registerBillingTools(server, baseCtx) {
2247
2554
  server.registerTool(
2248
2555
  "list_billing_plans",
2249
2556
  {
2250
2557
  description: "Return the authoritative Sakupa monthly plan catalog, exact limits, prices, catalog version and plan-change billing rules. This is read-only and does not require a site.",
2251
- inputSchema: {},
2558
+ inputSchema: { projectDir: projectDirInput },
2252
2559
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2253
2560
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
2254
2561
  },
2255
- async () => {
2562
+ async (args) => {
2256
2563
  try {
2564
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2257
2565
  const catalog = await ctx.client.getBillingPlanCatalog();
2258
2566
  return structuredToolResult({
2259
2567
  schemaVersion: 1,
@@ -2273,14 +2581,16 @@ function registerBillingTools(server, ctx) {
2273
2581
  {
2274
2582
  description: "Create a Stripe-hosted confirmation link for a manually selected subscription plan. Creating the link does not change billing; only the user can confirm on Stripe.",
2275
2583
  inputSchema: {
2584
+ projectDir: projectDirInput,
2276
2585
  targetPlan: plan,
2277
- operationId: z4.string().min(1)
2586
+ operationId: z5.string().min(1)
2278
2587
  },
2279
2588
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2280
2589
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
2281
2590
  },
2282
2591
  async (args) => {
2283
2592
  try {
2593
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2284
2594
  const site = requireSiteFile(ctx);
2285
2595
  const result = await ctx.client.changeSubscriptionPlan(site.credential, {
2286
2596
  siteId: site.siteId,
@@ -2339,6 +2649,15 @@ Workflow:
2339
2649
  5. create_support_ticket (subscribed sites) opens a support ticket; report_bug sends a
2340
2650
  sanitized diagnostic report after the user explicitly confirms it.
2341
2651
 
2652
+ Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
2653
+ binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2654
+ the directory the user is currently working in, on every call. Without it the server falls
2655
+ back to its startup directory, which may be a different project than the one the user is
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
2659
+ the directory they acted on \u2014 verify it matches the user's active project.
2660
+
2342
2661
  Safety boundaries:
2343
2662
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
2344
2663
  - Never upload source projects, secrets, .env files, private keys, archives, videos or audio.