@sakupa/mcp 0.7.10 → 0.7.12

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 +77 -38
  2. package/dist/index.js +77 -38
  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.10";
132
+ var SAKUPA_MCP_VERSION = "0.7.12";
133
133
 
134
134
  // ../core/dist/domain/errors.js
135
135
  var HTTP_STATUS = {
@@ -686,7 +686,7 @@ var HttpApiClient = class {
686
686
 
687
687
  // src/tools/definitions.ts
688
688
  import { randomUUID } from "node:crypto";
689
- import { promises as fs2 } from "node:fs";
689
+ import { existsSync as existsSync3, promises as fs2 } from "node:fs";
690
690
  import { join as join4, resolve as resolve3 } from "node:path";
691
691
  import { z as z3 } from "zod";
692
692
 
@@ -1196,14 +1196,18 @@ function deleteSiteFile(projectDir) {
1196
1196
  rmSync(path, { force: true });
1197
1197
  }
1198
1198
  }
1199
- function isInsideGitRepo(projectDir) {
1200
- let dir = projectDir;
1201
- for (; ; ) {
1202
- if (existsSync(join2(dir, ".git"))) return true;
1203
- const parent = dirname(dir);
1204
- if (parent === dir) return false;
1205
- dir = parent;
1199
+ function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY) {
1200
+ let cursor = startDir;
1201
+ for (let i = 0; i < maxLevels; i += 1) {
1202
+ const parent = dirname(cursor);
1203
+ if (parent === cursor) return null;
1204
+ if (predicate(parent)) return parent;
1205
+ cursor = parent;
1206
1206
  }
1207
+ return null;
1208
+ }
1209
+ function isInsideGitRepo(projectDir) {
1210
+ return existsSync(join2(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync(join2(dir, ".git"))) !== null;
1207
1211
  }
1208
1212
  function credentialGitReminder(projectDir) {
1209
1213
  if (!isInsideGitRepo(projectDir)) return "";
@@ -1354,7 +1358,7 @@ async function diagnoseBinding(input) {
1354
1358
  const apexResolves = apexAnswers.length > 0;
1355
1359
  const allOk = checks.every((c) => c.state === "ok");
1356
1360
  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).`);
1361
+ [${apexResolves ? "OK" : "MISSING"}] APEX ${apex} \u2014 ` + (apexResolves ? "resolves." : `does not resolve yet. Three-step fix, stop at the first that works: (1) try adding a plain record \u2014 type CNAME, host @, value ${input.servingTarget} (most panels and Cloudflare accept this directly; confirm past any MX-conflict warning if the domain sends no email). (2) If rejected, look for ALIAS / ANAME / CNAME-flattening in the record-type list \u2014 same host and value. (3) If the panel has neither, skip the apex: www alone works fine (certificates do not depend on the apex record); optionally add a URL redirect from @ to www.`);
1358
1362
  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
1363
  return { checks, apexResolves, allOk, checklist, layers };
1360
1364
  }
@@ -1437,7 +1441,7 @@ var LocalGuidanceError = class extends SakupaError {
1437
1441
  }
1438
1442
  };
1439
1443
  var projectDirInput = z2.string().optional().describe(
1440
- "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."
1444
+ "Absolute path of the user's PROJECT ROOT \u2014 the folder the user opened/works in (for framework projects: where package.json lives, NEVER the build-output subfolder like dist/out; the analyzer locates the output automatically). .sakupa/site.json lives here, so PASS THE SAME DIRECTORY EVERY TIME for the same project. When omitted the server falls back to its startup directory, which may not be where the user is working now."
1441
1445
  );
1442
1446
  function withProjectDir(ctx, projectDirArg) {
1443
1447
  if (projectDirArg === void 0) return ctx;
@@ -1479,16 +1483,7 @@ function requireSiteFile(ctx) {
1479
1483
  }
1480
1484
  return state.file;
1481
1485
  }
1482
- var STATIC_SUMMARY = {
1483
- not_found: "The required local project binding or resource is unavailable; if this project has no .sakupa/site.json yet, run deploy_site first.",
1484
- 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.",
1485
- invalid_request: "The request arguments or local project checks did not pass.",
1486
- validation_failed: "The request arguments or local project checks did not pass.",
1487
- state_conflict: "The resource state has changed; re-query the current status before deciding the next step.",
1488
- confirmation_required: "The site or billing state changed, so the previous confirmation is stale; run the preview again and confirm against the fresh snapshot.",
1489
- payment_required: "This operation requires an active subscription; check billing_status first.",
1490
- rate_limited: "The server rate limit was reached; retry after the returned wait time."
1491
- };
1486
+ var UNAUTHORIZED_SUMMARY = "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.";
1492
1487
  function toolError(e) {
1493
1488
  const errorCode = isSakupaError(e) ? e.code : "internal";
1494
1489
  const retryable = errorCode === "rate_limited" || errorCode === "internal";
@@ -1507,7 +1502,8 @@ function toolError(e) {
1507
1502
  )
1508
1503
  ) : void 0;
1509
1504
  const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
1510
- 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.");
1505
+ const serverGuidance = isSakupaError(e) && errorCode !== "internal" && errorCode !== "unauthorized" && errorCode !== "upgrade_required" && e.message.trim().length > 0 ? e.message : void 0;
1506
+ 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.` : errorCode === "unauthorized" ? UNAUTHORIZED_SUMMARY : serverGuidance ?? (retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : "The operation failed; no server-internal details are exposed.");
1511
1507
  const result = structuredToolResult({
1512
1508
  schemaVersion: 1,
1513
1509
  outcome: "failed",
@@ -1661,6 +1657,20 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
1661
1657
  };
1662
1658
  }
1663
1659
  }
1660
+ function projectRootAbove(projectDir) {
1661
+ if (existsSync3(join4(projectDir, "package.json"))) return null;
1662
+ return findAncestor(projectDir, (dir) => existsSync3(join4(dir, "package.json")), 4);
1663
+ }
1664
+ function findNeighborBinding(projectDir, outputRel) {
1665
+ const bound = (dir) => loadSiteFile(dir).kind !== "absent";
1666
+ const above = findAncestor(projectDir, bound, 3);
1667
+ if (above) return above;
1668
+ if (outputRel && outputRel !== ".") {
1669
+ const outputAbs = resolve3(projectDir, outputRel);
1670
+ if (bound(outputAbs)) return outputAbs;
1671
+ }
1672
+ return null;
1673
+ }
1664
1674
  function freeSiteCreationBarrier() {
1665
1675
  const recent = listRecentCreations(Date.now());
1666
1676
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -1721,6 +1731,9 @@ Next action: ${analysis.suggestedNextAction}`,
1721
1731
  publicConfirmed: z3.boolean().optional().describe(
1722
1732
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1723
1733
  ),
1734
+ subprojectConfirmed: z3.boolean().optional().describe(
1735
+ "Only when creating a NEW site in a subfolder of a package.json project: the user explicitly confirmed this subfolder is an INDEPENDENT site, not the project's build output."
1736
+ ),
1724
1737
  lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1725
1738
  }
1726
1739
  },
@@ -1749,16 +1762,34 @@ Next action: ${analysis.suggestedNextAction}`,
1749
1762
  }
1750
1763
  const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
1751
1764
  if (!existing) {
1765
+ const rootAbove = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
1766
+ if (rootAbove) {
1767
+ return text(
1768
+ "not_project_root",
1769
+ `${ctx.projectDir} has no package.json but ${rootAbove} does \u2014 this directory is a SUBFOLDER of that project (typically its build output), and .sakupa must live at the project ROOT. Re-run deploy_site with projectDir: ${rootAbove}. Only if the user explicitly says this subfolder is an INDEPENDENT site (e.g. a docs/ site inside a repo), re-run with subprojectConfirmed: true. Nothing was deployed and no site was created.`,
1770
+ { projectRoot: rootAbove, confirmationField: "subprojectConfirmed" },
1771
+ "blocked"
1772
+ );
1773
+ }
1774
+ const neighbor = findNeighborBinding(ctx.projectDir, analysis.recommendedOutputDir);
1775
+ if (neighbor) {
1776
+ return text(
1777
+ "neighbor_binding_found",
1778
+ `No .sakupa binding in ${ctx.projectDir}, but one EXISTS at ${neighbor} \u2014 this looks like the same project addressed at a different directory level. To update that existing site, re-run deploy_site with projectDir: ${neighbor}. Only if the user explicitly wants a SEPARATE new site, move this deploy to a directory outside that project. Nothing was deployed and no site was created.`,
1779
+ { neighborProjectDir: neighbor },
1780
+ "blocked"
1781
+ );
1782
+ }
1752
1783
  const barrier = freeSiteCreationBarrier();
1753
1784
  if (barrier) return barrier;
1754
- }
1755
- if (!existing && args.publicConfirmed !== true) {
1756
- return text(
1757
- "public_deployment_confirmation_required",
1758
- `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`,
1759
- { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
1760
- "waiting_user"
1761
- );
1785
+ if (args.publicConfirmed !== true) {
1786
+ return text(
1787
+ "public_deployment_confirmation_required",
1788
+ `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`,
1789
+ { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
1790
+ "waiting_user"
1791
+ );
1792
+ }
1762
1793
  }
1763
1794
  ensureUploadSizeWithinLimits(manifest, !existing);
1764
1795
  if (!existing) {
@@ -1982,7 +2013,7 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1982
2013
  server.registerTool(
1983
2014
  "bind_domain",
1984
2015
  {
1985
- description: `Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent ${previewHostPattern} URL keeps working alongside it. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership. A binding request never reserves the domain: whoever proves DNS control first gets it, and unverified requests expire after 72 hours. Call again with verificationId to check progress.`,
2016
+ description: `Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent ${previewHostPattern} URL keeps working alongside it. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership, and bindings are ALWAYS challengeable: whoever proves CURRENT DNS control takes the domain, even from an existing binding (the displaced site keeps its subscription, content and permanent URL). Unverified requests expire after 72 hours. Call again with action "status" to check progress.`,
1986
2017
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1987
2018
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1988
2019
  inputSchema: {
@@ -2051,17 +2082,19 @@ ${res2.message}
2051
2082
  "domain_verification_started",
2052
2083
  `Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
2053
2084
 
2054
- Add ALL THREE DNS records NOW (adding them together lets verification, certificate issuance and serving complete without further record changes):
2085
+ STEP 1 of 2 \u2014 add ALL THREE records NOW:
2055
2086
 
2056
2087
  1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
2057
2088
  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).
2089
+ 3) APEX host: @ -> ${res.servingTarget}. Try a plain CNAME at host @ first (most panels accept it); if rejected use ALIAS / ANAME / CNAME-flattening; if the panel has neither, skip it \u2014 www alone works, and a URL redirect from @ to www covers bare-domain visitors.
2059
2090
 
2060
2091
  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
2092
 
2062
2093
  Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
2063
2094
 
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.`,
2095
+ STEP 2 (after ownership verifies): Cloudflare issues certificate-validation TXT records \u2014 the verification result and bind_domain "status" list them the moment they exist; relay each to the user and add them too. Everything then completes automatically.
2096
+
2097
+ Drive the whole flow with bind_domain "status": 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.`,
2065
2098
  {
2066
2099
  verificationId: res.verificationId,
2067
2100
  apexDomain: apex,
@@ -2371,9 +2404,10 @@ ${res.archiveUrl}`,
2371
2404
  ...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
2372
2405
  };
2373
2406
  if (args.confirmSubmit !== true) {
2407
+ const contactNote = args.contactEmail !== void 0 ? "note that their contact email is attached for follow-up. " : "ASK THEM ONCE whether they want to attach a contact email for follow-up (optional \u2014 omit if declined; include it as contactEmail when they do). ";
2374
2408
  return textJson(
2375
2409
  "bug_report_preview_ready",
2376
- "Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Please show it to the user; re-run report_bug with confirmSubmit: true to submit.",
2410
+ `Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Show it to the user, and ${contactNote}Then re-run report_bug with confirmSubmit: true to submit.`,
2377
2411
  payload,
2378
2412
  "preview"
2379
2413
  );
@@ -2709,13 +2743,18 @@ Workflow:
2709
2743
 
2710
2744
  Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
2711
2745
  binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2712
- the directory the user is currently working in, on every call. Without it the server falls
2713
- back to its startup directory, which may be a different project than the one the user is
2714
- looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2746
+ the user's PROJECT ROOT, the SAME directory every time for the same project: the folder
2747
+ the user opened (for framework projects, where package.json lives \u2014 never the dist/out
2748
+ build folder; output is auto-detected). Without it the server falls back to its startup
2749
+ directory, which may be a different project than the one the user is looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2715
2750
  Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
2716
2751
  refresh_site, delete_site and unbind_domain echo
2717
2752
  the directory they acted on \u2014 verify it matches the user's active project.
2718
2753
 
2754
+ When the same operation fails twice in a row, or the user is clearly stuck or
2755
+ frustrated, proactively offer report_bug: it files the problem into Sakupa's ticket and
2756
+ alert stream, and you should attach your own factual account via agentContext.
2757
+
2719
2758
  Safety boundaries:
2720
2759
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
2721
2760
  - Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
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.10";
127
+ var SAKUPA_MCP_VERSION = "0.7.12";
128
128
 
129
129
  // ../core/dist/domain/errors.js
130
130
  var HTTP_STATUS = {
@@ -839,14 +839,18 @@ function deleteSiteFile(projectDir) {
839
839
  rmSync(path, { force: true });
840
840
  }
841
841
  }
842
- function isInsideGitRepo(projectDir) {
843
- let dir = projectDir;
844
- for (; ; ) {
845
- if (existsSync(join(dir, ".git"))) return true;
846
- const parent = dirname(dir);
847
- if (parent === dir) return false;
848
- dir = parent;
842
+ function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY) {
843
+ let cursor = startDir;
844
+ for (let i = 0; i < maxLevels; i += 1) {
845
+ const parent = dirname(cursor);
846
+ if (parent === cursor) return null;
847
+ if (predicate(parent)) return parent;
848
+ cursor = parent;
849
849
  }
850
+ return null;
851
+ }
852
+ function isInsideGitRepo(projectDir) {
853
+ return existsSync(join(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync(join(dir, ".git"))) !== null;
850
854
  }
851
855
  function credentialGitReminder(projectDir) {
852
856
  if (!isInsideGitRepo(projectDir)) return "";
@@ -1326,7 +1330,7 @@ var LocalGuidanceError = class extends SakupaError {
1326
1330
  }
1327
1331
  };
1328
1332
  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."
1333
+ "Absolute path of the user's PROJECT ROOT \u2014 the folder the user opened/works in (for framework projects: where package.json lives, NEVER the build-output subfolder like dist/out; the analyzer locates the output automatically). .sakupa/site.json lives here, so PASS THE SAME DIRECTORY EVERY TIME for the same project. When omitted the server falls back to its startup directory, which may not be where the user is working now."
1330
1334
  );
1331
1335
  function withProjectDir(ctx, projectDirArg) {
1332
1336
  if (projectDirArg === void 0) return ctx;
@@ -1368,16 +1372,7 @@ function requireSiteFile(ctx) {
1368
1372
  }
1369
1373
  return state.file;
1370
1374
  }
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
- };
1375
+ var UNAUTHORIZED_SUMMARY = "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.";
1381
1376
  function toolError(e) {
1382
1377
  const errorCode = isSakupaError(e) ? e.code : "internal";
1383
1378
  const retryable = errorCode === "rate_limited" || errorCode === "internal";
@@ -1396,7 +1391,8 @@ function toolError(e) {
1396
1391
  )
1397
1392
  ) : void 0;
1398
1393
  const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
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.");
1394
+ const serverGuidance = isSakupaError(e) && errorCode !== "internal" && errorCode !== "unauthorized" && errorCode !== "upgrade_required" && e.message.trim().length > 0 ? e.message : void 0;
1395
+ 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.` : errorCode === "unauthorized" ? UNAUTHORIZED_SUMMARY : serverGuidance ?? (retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : "The operation failed; no server-internal details are exposed.");
1400
1396
  const result = structuredToolResult({
1401
1397
  schemaVersion: 1,
1402
1398
  outcome: "failed",
@@ -1414,7 +1410,7 @@ function toolError(e) {
1414
1410
 
1415
1411
  // src/tools/definitions.ts
1416
1412
  import { randomUUID } from "node:crypto";
1417
- import { promises as fs2 } from "node:fs";
1413
+ import { existsSync as existsSync3, promises as fs2 } from "node:fs";
1418
1414
  import { join as join4, resolve as resolve3 } from "node:path";
1419
1415
  import { z as z3 } from "zod";
1420
1416
 
@@ -1562,7 +1558,7 @@ async function diagnoseBinding(input) {
1562
1558
  const apexResolves = apexAnswers.length > 0;
1563
1559
  const allOk = checks.every((c) => c.state === "ok");
1564
1560
  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).`);
1561
+ [${apexResolves ? "OK" : "MISSING"}] APEX ${apex} \u2014 ` + (apexResolves ? "resolves." : `does not resolve yet. Three-step fix, stop at the first that works: (1) try adding a plain record \u2014 type CNAME, host @, value ${input.servingTarget} (most panels and Cloudflare accept this directly; confirm past any MX-conflict warning if the domain sends no email). (2) If rejected, look for ALIAS / ANAME / CNAME-flattening in the record-type list \u2014 same host and value. (3) If the panel has neither, skip the apex: www alone works fine (certificates do not depend on the apex record); optionally add a URL redirect from @ to www.`);
1566
1562
  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
1563
  return { checks, apexResolves, allOk, checklist, layers };
1568
1564
  }
@@ -1725,6 +1721,20 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
1725
1721
  };
1726
1722
  }
1727
1723
  }
1724
+ function projectRootAbove(projectDir) {
1725
+ if (existsSync3(join4(projectDir, "package.json"))) return null;
1726
+ return findAncestor(projectDir, (dir) => existsSync3(join4(dir, "package.json")), 4);
1727
+ }
1728
+ function findNeighborBinding(projectDir, outputRel) {
1729
+ const bound = (dir) => loadSiteFile(dir).kind !== "absent";
1730
+ const above = findAncestor(projectDir, bound, 3);
1731
+ if (above) return above;
1732
+ if (outputRel && outputRel !== ".") {
1733
+ const outputAbs = resolve3(projectDir, outputRel);
1734
+ if (bound(outputAbs)) return outputAbs;
1735
+ }
1736
+ return null;
1737
+ }
1728
1738
  function freeSiteCreationBarrier() {
1729
1739
  const recent = listRecentCreations(Date.now());
1730
1740
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -1785,6 +1795,9 @@ Next action: ${analysis.suggestedNextAction}`,
1785
1795
  publicConfirmed: z3.boolean().optional().describe(
1786
1796
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1787
1797
  ),
1798
+ subprojectConfirmed: z3.boolean().optional().describe(
1799
+ "Only when creating a NEW site in a subfolder of a package.json project: the user explicitly confirmed this subfolder is an INDEPENDENT site, not the project's build output."
1800
+ ),
1788
1801
  lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1789
1802
  }
1790
1803
  },
@@ -1813,16 +1826,34 @@ Next action: ${analysis.suggestedNextAction}`,
1813
1826
  }
1814
1827
  const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
1815
1828
  if (!existing) {
1829
+ const rootAbove = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
1830
+ if (rootAbove) {
1831
+ return text(
1832
+ "not_project_root",
1833
+ `${ctx.projectDir} has no package.json but ${rootAbove} does \u2014 this directory is a SUBFOLDER of that project (typically its build output), and .sakupa must live at the project ROOT. Re-run deploy_site with projectDir: ${rootAbove}. Only if the user explicitly says this subfolder is an INDEPENDENT site (e.g. a docs/ site inside a repo), re-run with subprojectConfirmed: true. Nothing was deployed and no site was created.`,
1834
+ { projectRoot: rootAbove, confirmationField: "subprojectConfirmed" },
1835
+ "blocked"
1836
+ );
1837
+ }
1838
+ const neighbor = findNeighborBinding(ctx.projectDir, analysis.recommendedOutputDir);
1839
+ if (neighbor) {
1840
+ return text(
1841
+ "neighbor_binding_found",
1842
+ `No .sakupa binding in ${ctx.projectDir}, but one EXISTS at ${neighbor} \u2014 this looks like the same project addressed at a different directory level. To update that existing site, re-run deploy_site with projectDir: ${neighbor}. Only if the user explicitly wants a SEPARATE new site, move this deploy to a directory outside that project. Nothing was deployed and no site was created.`,
1843
+ { neighborProjectDir: neighbor },
1844
+ "blocked"
1845
+ );
1846
+ }
1816
1847
  const barrier = freeSiteCreationBarrier();
1817
1848
  if (barrier) return barrier;
1818
- }
1819
- if (!existing && args.publicConfirmed !== true) {
1820
- return text(
1821
- "public_deployment_confirmation_required",
1822
- `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`,
1823
- { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
1824
- "waiting_user"
1825
- );
1849
+ if (args.publicConfirmed !== true) {
1850
+ return text(
1851
+ "public_deployment_confirmation_required",
1852
+ `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`,
1853
+ { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
1854
+ "waiting_user"
1855
+ );
1856
+ }
1826
1857
  }
1827
1858
  ensureUploadSizeWithinLimits(manifest, !existing);
1828
1859
  if (!existing) {
@@ -2046,7 +2077,7 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
2046
2077
  server.registerTool(
2047
2078
  "bind_domain",
2048
2079
  {
2049
- description: `Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent ${previewHostPattern} URL keeps working alongside it. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership. A binding request never reserves the domain: whoever proves DNS control first gets it, and unverified requests expire after 72 hours. Call again with verificationId to check progress.`,
2080
+ description: `Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent ${previewHostPattern} URL keeps working alongside it. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership, and bindings are ALWAYS challengeable: whoever proves CURRENT DNS control takes the domain, even from an existing binding (the displaced site keeps its subscription, content and permanent URL). Unverified requests expire after 72 hours. Call again with action "status" to check progress.`,
2050
2081
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2051
2082
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2052
2083
  inputSchema: {
@@ -2115,17 +2146,19 @@ ${res2.message}
2115
2146
  "domain_verification_started",
2116
2147
  `Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
2117
2148
 
2118
- Add ALL THREE DNS records NOW (adding them together lets verification, certificate issuance and serving complete without further record changes):
2149
+ STEP 1 of 2 \u2014 add ALL THREE records NOW:
2119
2150
 
2120
2151
  1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
2121
2152
  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).
2153
+ 3) APEX host: @ -> ${res.servingTarget}. Try a plain CNAME at host @ first (most panels accept it); if rejected use ALIAS / ANAME / CNAME-flattening; if the panel has neither, skip it \u2014 www alone works, and a URL redirect from @ to www covers bare-domain visitors.
2123
2154
 
2124
2155
  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
2156
 
2126
2157
  Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
2127
2158
 
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.`,
2159
+ STEP 2 (after ownership verifies): Cloudflare issues certificate-validation TXT records \u2014 the verification result and bind_domain "status" list them the moment they exist; relay each to the user and add them too. Everything then completes automatically.
2160
+
2161
+ Drive the whole flow with bind_domain "status": 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.`,
2129
2162
  {
2130
2163
  verificationId: res.verificationId,
2131
2164
  apexDomain: apex,
@@ -2435,9 +2468,10 @@ ${res.archiveUrl}`,
2435
2468
  ...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
2436
2469
  };
2437
2470
  if (args.confirmSubmit !== true) {
2471
+ const contactNote = args.contactEmail !== void 0 ? "note that their contact email is attached for follow-up. " : "ASK THEM ONCE whether they want to attach a contact email for follow-up (optional \u2014 omit if declined; include it as contactEmail when they do). ";
2438
2472
  return textJson(
2439
2473
  "bug_report_preview_ready",
2440
- "Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Please show it to the user; re-run report_bug with confirmSubmit: true to submit.",
2474
+ `Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Show it to the user, and ${contactNote}Then re-run report_bug with confirmSubmit: true to submit.`,
2441
2475
  payload,
2442
2476
  "preview"
2443
2477
  );
@@ -2704,13 +2738,18 @@ Workflow:
2704
2738
 
2705
2739
  Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
2706
2740
  binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2707
- the directory the user is currently working in, on every call. Without it the server falls
2708
- back to its startup directory, which may be a different project than the one the user is
2709
- looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2741
+ the user's PROJECT ROOT, the SAME directory every time for the same project: the folder
2742
+ the user opened (for framework projects, where package.json lives \u2014 never the dist/out
2743
+ build folder; output is auto-detected). Without it the server falls back to its startup
2744
+ directory, which may be a different project than the one the user is looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2710
2745
  Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
2711
2746
  refresh_site, delete_site and unbind_domain echo
2712
2747
  the directory they acted on \u2014 verify it matches the user's active project.
2713
2748
 
2749
+ When the same operation fails twice in a row, or the user is clearly stuck or
2750
+ frustrated, proactively offer report_bug: it files the problem into Sakupa's ticket and
2751
+ alert stream, and you should attach your own factual account via agentContext.
2752
+
2714
2753
  Safety boundaries:
2715
2754
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
2716
2755
  - Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakupa/mcp",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
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",