@sakupa/mcp 0.7.3 → 0.7.4

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 +96 -29
  2. package/dist/index.js +97 -30
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -128,7 +128,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
128
128
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
129
129
 
130
130
  // ../core/dist/domain/version.js
131
- var SAKUPA_MCP_VERSION = "0.7.3";
131
+ var SAKUPA_MCP_VERSION = "0.7.4";
132
132
 
133
133
  // ../core/dist/domain/errors.js
134
134
  var HTTP_STATUS = {
@@ -444,6 +444,9 @@ function safeDecode(bytes) {
444
444
  }
445
445
  }
446
446
 
447
+ // ../core/dist/domain/credentials.js
448
+ var CREDENTIAL_PATTERN = /^sk_[A-Za-z0-9_-]{43}$/;
449
+
447
450
  // ../core/dist/domain/hashing.js
448
451
  async function sha256Hex(bytes) {
449
452
  const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
@@ -458,10 +461,6 @@ var CREDENTIAL_HEADER = "x-sakupa-credential";
458
461
  var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
459
462
  var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
460
463
 
461
- // ../core/dist/services/sites.js
462
- var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
463
- var utf8Encoder = new TextEncoder();
464
-
465
464
  // ../core/dist/services/subscriptions.js
466
465
  var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
467
466
 
@@ -1128,20 +1127,42 @@ var SITE_FILE = "site.json";
1128
1127
  function siteFilePath(projectDir) {
1129
1128
  return join2(projectDir, SITE_DIR, SITE_FILE);
1130
1129
  }
1131
- function readSiteFile(projectDir) {
1130
+ function loadSiteFile(projectDir) {
1132
1131
  const path = siteFilePath(projectDir);
1132
+ if (!existsSync(path)) return { kind: "absent" };
1133
1133
  let raw;
1134
1134
  try {
1135
1135
  raw = readFileSync(path, "utf8");
1136
- } catch {
1137
- return null;
1136
+ } catch (err) {
1137
+ return {
1138
+ kind: "corrupted",
1139
+ problem: `the file exists but could not be read (${err instanceof Error ? err.message : String(err)})`
1140
+ };
1138
1141
  }
1142
+ let parsed;
1139
1143
  try {
1140
- const parsed = JSON.parse(raw);
1141
- if (typeof parsed !== "object" || parsed === null) return null;
1142
- if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) return null;
1143
- if (typeof parsed.credential !== "string" || parsed.credential.length === 0) return null;
1144
+ parsed = JSON.parse(raw);
1145
+ } catch {
1146
+ return { kind: "corrupted", problem: "the file exists but is not valid JSON" };
1147
+ }
1148
+ if (typeof parsed !== "object" || parsed === null) {
1149
+ return { kind: "corrupted", problem: "the file does not contain a JSON object" };
1150
+ }
1151
+ if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) {
1152
+ return { kind: "corrupted", problem: "the siteId field is missing or empty" };
1153
+ }
1154
+ if (typeof parsed.credential !== "string" || parsed.credential.length === 0) {
1155
+ return { kind: "corrupted", problem: "the credential field is missing or empty" };
1156
+ }
1157
+ if (!CREDENTIAL_PATTERN.test(parsed.credential)) {
1144
1158
  return {
1159
+ kind: "corrupted",
1160
+ problem: "the credential does not match the shape Sakupa issues (sk_ followed by exactly 43 URL-safe base64 characters) \u2014 it looks locally altered or damaged"
1161
+ };
1162
+ }
1163
+ return {
1164
+ kind: "ok",
1165
+ file: {
1145
1166
  siteId: parsed.siteId,
1146
1167
  credential: parsed.credential,
1147
1168
  createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
@@ -1149,12 +1170,26 @@ function readSiteFile(projectDir) {
1149
1170
  ...typeof parsed.shortId === "string" ? { shortId: parsed.shortId } : {},
1150
1171
  ...typeof parsed.url === "string" ? { url: parsed.url } : {},
1151
1172
  ...typeof parsed.boundDomain === "string" ? { boundDomain: parsed.boundDomain } : {}
1152
- };
1153
- } catch {
1154
- return null;
1155
- }
1173
+ }
1174
+ };
1156
1175
  }
1157
- function writeSiteFile(projectDir, file) {
1176
+ function siteFileRecoveryGuidance(projectDir) {
1177
+ return `The site itself is intact on the server; only the local binding file (${siteFilePath(projectDir)}) is the problem. Restore the file (from a backup or by undoing the local edit). Do NOT delete it to work around the error: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site. Publishing this project as a brand-NEW site requires the user to manually delete the .sakupa directory first \u2014 the tool will never overwrite it.`;
1178
+ }
1179
+ function writeSiteFile(projectDir, file, opts = {}) {
1180
+ if (opts.allowReplace !== true) {
1181
+ const existing = loadSiteFile(projectDir);
1182
+ if (existing.kind === "corrupted") {
1183
+ throw new Error(
1184
+ `Refusing to overwrite ${siteFilePath(projectDir)}: ${existing.problem}. ` + siteFileRecoveryGuidance(projectDir)
1185
+ );
1186
+ }
1187
+ if (existing.kind === "ok" && existing.file.siteId !== file.siteId) {
1188
+ throw new Error(
1189
+ `Refusing to overwrite ${siteFilePath(projectDir)}: it already binds this project to site ${existing.file.siteId}. ` + siteFileRecoveryGuidance(projectDir)
1190
+ );
1191
+ }
1192
+ }
1158
1193
  const dir = join2(projectDir, SITE_DIR);
1159
1194
  mkdirSync(dir, { recursive: true });
1160
1195
  const path = join2(dir, SITE_FILE);
@@ -1232,14 +1267,20 @@ function structuredToolResult(envelope) {
1232
1267
 
1233
1268
  // src/tools/context.ts
1234
1269
  function requireSiteFile(ctx) {
1235
- const file = readSiteFile(ctx.projectDir);
1236
- if (!file) {
1270
+ const state = loadSiteFile(ctx.projectDir);
1271
+ if (state.kind === "corrupted") {
1272
+ throw new SakupaError(
1273
+ "invalid_request",
1274
+ `.sakupa/site.json in ${ctx.projectDir} is damaged: ${state.problem}. ` + siteFileRecoveryGuidance(ctx.projectDir)
1275
+ );
1276
+ }
1277
+ if (state.kind === "absent") {
1237
1278
  throw new SakupaError(
1238
1279
  "not_found",
1239
1280
  `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.`
1240
1281
  );
1241
1282
  }
1242
- return file;
1283
+ return state.file;
1243
1284
  }
1244
1285
  function toolError(e) {
1245
1286
  const errorCode = isSakupaError(e) ? e.code : "internal";
@@ -1460,7 +1501,18 @@ Next action: ${analysis.suggestedNextAction}`,
1460
1501
  const files = analysis.files;
1461
1502
  const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1462
1503
  const manifest = await buildHashedManifest(files, outputAbs);
1463
- const existing = readSiteFile(ctx.projectDir);
1504
+ const siteFileState = loadSiteFile(ctx.projectDir);
1505
+ if (siteFileState.kind === "corrupted") {
1506
+ return text(
1507
+ "site_file_corrupted",
1508
+ `This project's .sakupa/site.json is damaged: ${siteFileState.problem}.
1509
+
1510
+ ` + siteFileRecoveryGuidance(ctx.projectDir) + "\n\nNothing was deployed and no new site was created.",
1511
+ { problem: siteFileState.problem },
1512
+ "blocked"
1513
+ );
1514
+ }
1515
+ const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
1464
1516
  if (!existing && args.publicConfirmed !== true) {
1465
1517
  return text(
1466
1518
  "public_deployment_confirmation_required",
@@ -1540,6 +1592,16 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1540
1592
  try {
1541
1593
  update = await updateOnce(false);
1542
1594
  } catch (e) {
1595
+ if (isSakupaError(e) && e.code === "unauthorized") {
1596
+ return text(
1597
+ "credential_mismatch",
1598
+ `The server rejected the credential in .sakupa/site.json for site ${existing.siteId} \u2014 the local file no longer matches the server-side verifier (most often a local edit or merge damage).
1599
+
1600
+ ` + siteFileRecoveryGuidance(ctx.projectDir) + "\n\nNothing was deployed and no new site was created.",
1601
+ { siteId: existing.siteId },
1602
+ "blocked"
1603
+ );
1604
+ }
1543
1605
  if (!isIncrementalReuseFailure(e)) throw e;
1544
1606
  update = await updateOnce(true);
1545
1607
  }
@@ -1890,13 +1952,17 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
1890
1952
  const res = await ctx.client.completeRecovery(args.verificationId, {
1891
1953
  ...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
1892
1954
  });
1893
- writeSiteFile(ctx.projectDir, {
1894
- siteId: res.siteId,
1895
- ...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
1896
- credential: res.credential,
1897
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1898
- apiBaseUrl: ctx.apiBaseUrl
1899
- });
1955
+ writeSiteFile(
1956
+ ctx.projectDir,
1957
+ {
1958
+ siteId: res.siteId,
1959
+ ...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
1960
+ credential: res.credential,
1961
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1962
+ apiBaseUrl: ctx.apiBaseUrl
1963
+ },
1964
+ { allowReplace: true }
1965
+ );
1900
1966
  return text(
1901
1967
  "domain_recovery_completed",
1902
1968
  `Recovery complete.
@@ -1973,7 +2039,8 @@ ${res.archiveUrl}`,
1973
2039
  },
1974
2040
  async (args) => {
1975
2041
  try {
1976
- const site = readSiteFile(ctx.projectDir);
2042
+ const siteState = loadSiteFile(ctx.projectDir);
2043
+ const site = siteState.kind === "ok" ? siteState.file : null;
1977
2044
  const diagnostics = {
1978
2045
  toolName: args.toolName,
1979
2046
  ...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
package/dist/index.js CHANGED
@@ -123,7 +123,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
123
123
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
124
124
 
125
125
  // ../core/dist/domain/version.js
126
- var SAKUPA_MCP_VERSION = "0.7.3";
126
+ var SAKUPA_MCP_VERSION = "0.7.4";
127
127
 
128
128
  // ../core/dist/domain/errors.js
129
129
  var HTTP_STATUS = {
@@ -439,6 +439,9 @@ function safeDecode(bytes) {
439
439
  }
440
440
  }
441
441
 
442
+ // ../core/dist/domain/credentials.js
443
+ var CREDENTIAL_PATTERN = /^sk_[A-Za-z0-9_-]{43}$/;
444
+
442
445
  // ../core/dist/domain/hashing.js
443
446
  async function sha256Hex(bytes) {
444
447
  const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
@@ -453,10 +456,6 @@ var CREDENTIAL_HEADER = "x-sakupa-credential";
453
456
  var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
454
457
  var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
455
458
 
456
- // ../core/dist/services/sites.js
457
- var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
458
- var utf8Encoder = new TextEncoder();
459
-
460
459
  // ../core/dist/services/subscriptions.js
461
460
  var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
462
461
 
@@ -764,20 +763,42 @@ var SITE_FILE = "site.json";
764
763
  function siteFilePath(projectDir) {
765
764
  return join(projectDir, SITE_DIR, SITE_FILE);
766
765
  }
767
- function readSiteFile(projectDir) {
766
+ function loadSiteFile(projectDir) {
768
767
  const path = siteFilePath(projectDir);
768
+ if (!existsSync(path)) return { kind: "absent" };
769
769
  let raw;
770
770
  try {
771
771
  raw = readFileSync(path, "utf8");
772
- } catch {
773
- return null;
772
+ } catch (err) {
773
+ return {
774
+ kind: "corrupted",
775
+ problem: `the file exists but could not be read (${err instanceof Error ? err.message : String(err)})`
776
+ };
774
777
  }
778
+ let parsed;
775
779
  try {
776
- const parsed = JSON.parse(raw);
777
- if (typeof parsed !== "object" || parsed === null) return null;
778
- if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) return null;
779
- if (typeof parsed.credential !== "string" || parsed.credential.length === 0) return null;
780
+ parsed = JSON.parse(raw);
781
+ } catch {
782
+ return { kind: "corrupted", problem: "the file exists but is not valid JSON" };
783
+ }
784
+ if (typeof parsed !== "object" || parsed === null) {
785
+ return { kind: "corrupted", problem: "the file does not contain a JSON object" };
786
+ }
787
+ if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) {
788
+ return { kind: "corrupted", problem: "the siteId field is missing or empty" };
789
+ }
790
+ if (typeof parsed.credential !== "string" || parsed.credential.length === 0) {
791
+ return { kind: "corrupted", problem: "the credential field is missing or empty" };
792
+ }
793
+ if (!CREDENTIAL_PATTERN.test(parsed.credential)) {
780
794
  return {
795
+ kind: "corrupted",
796
+ problem: "the credential does not match the shape Sakupa issues (sk_ followed by exactly 43 URL-safe base64 characters) \u2014 it looks locally altered or damaged"
797
+ };
798
+ }
799
+ return {
800
+ kind: "ok",
801
+ file: {
781
802
  siteId: parsed.siteId,
782
803
  credential: parsed.credential,
783
804
  createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
@@ -785,12 +806,26 @@ function readSiteFile(projectDir) {
785
806
  ...typeof parsed.shortId === "string" ? { shortId: parsed.shortId } : {},
786
807
  ...typeof parsed.url === "string" ? { url: parsed.url } : {},
787
808
  ...typeof parsed.boundDomain === "string" ? { boundDomain: parsed.boundDomain } : {}
788
- };
789
- } catch {
790
- return null;
791
- }
809
+ }
810
+ };
792
811
  }
793
- function writeSiteFile(projectDir, file) {
812
+ function siteFileRecoveryGuidance(projectDir) {
813
+ return `The site itself is intact on the server; only the local binding file (${siteFilePath(projectDir)}) is the problem. Restore the file (from a backup or by undoing the local edit). Do NOT delete it to work around the error: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site. Publishing this project as a brand-NEW site requires the user to manually delete the .sakupa directory first \u2014 the tool will never overwrite it.`;
814
+ }
815
+ function writeSiteFile(projectDir, file, opts = {}) {
816
+ if (opts.allowReplace !== true) {
817
+ const existing = loadSiteFile(projectDir);
818
+ if (existing.kind === "corrupted") {
819
+ throw new Error(
820
+ `Refusing to overwrite ${siteFilePath(projectDir)}: ${existing.problem}. ` + siteFileRecoveryGuidance(projectDir)
821
+ );
822
+ }
823
+ if (existing.kind === "ok" && existing.file.siteId !== file.siteId) {
824
+ throw new Error(
825
+ `Refusing to overwrite ${siteFilePath(projectDir)}: it already binds this project to site ${existing.file.siteId}. ` + siteFileRecoveryGuidance(projectDir)
826
+ );
827
+ }
828
+ }
794
829
  const dir = join(projectDir, SITE_DIR);
795
830
  mkdirSync(dir, { recursive: true });
796
831
  const path = join(dir, SITE_FILE);
@@ -1290,14 +1325,20 @@ function structuredToolResult(envelope) {
1290
1325
 
1291
1326
  // src/tools/context.ts
1292
1327
  function requireSiteFile(ctx) {
1293
- const file = readSiteFile(ctx.projectDir);
1294
- if (!file) {
1328
+ const state = loadSiteFile(ctx.projectDir);
1329
+ if (state.kind === "corrupted") {
1330
+ throw new SakupaError(
1331
+ "invalid_request",
1332
+ `.sakupa/site.json in ${ctx.projectDir} is damaged: ${state.problem}. ` + siteFileRecoveryGuidance(ctx.projectDir)
1333
+ );
1334
+ }
1335
+ if (state.kind === "absent") {
1295
1336
  throw new SakupaError(
1296
1337
  "not_found",
1297
1338
  `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.`
1298
1339
  );
1299
1340
  }
1300
- return file;
1341
+ return state.file;
1301
1342
  }
1302
1343
  function toolError(e) {
1303
1344
  const errorCode = isSakupaError(e) ? e.code : "internal";
@@ -1522,7 +1563,18 @@ Next action: ${analysis.suggestedNextAction}`,
1522
1563
  const files = analysis.files;
1523
1564
  const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1524
1565
  const manifest = await buildHashedManifest(files, outputAbs);
1525
- const existing = readSiteFile(ctx.projectDir);
1566
+ const siteFileState = loadSiteFile(ctx.projectDir);
1567
+ if (siteFileState.kind === "corrupted") {
1568
+ return text(
1569
+ "site_file_corrupted",
1570
+ `This project's .sakupa/site.json is damaged: ${siteFileState.problem}.
1571
+
1572
+ ` + siteFileRecoveryGuidance(ctx.projectDir) + "\n\nNothing was deployed and no new site was created.",
1573
+ { problem: siteFileState.problem },
1574
+ "blocked"
1575
+ );
1576
+ }
1577
+ const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
1526
1578
  if (!existing && args.publicConfirmed !== true) {
1527
1579
  return text(
1528
1580
  "public_deployment_confirmation_required",
@@ -1602,6 +1654,16 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1602
1654
  try {
1603
1655
  update = await updateOnce(false);
1604
1656
  } catch (e) {
1657
+ if (isSakupaError(e) && e.code === "unauthorized") {
1658
+ return text(
1659
+ "credential_mismatch",
1660
+ `The server rejected the credential in .sakupa/site.json for site ${existing.siteId} \u2014 the local file no longer matches the server-side verifier (most often a local edit or merge damage).
1661
+
1662
+ ` + siteFileRecoveryGuidance(ctx.projectDir) + "\n\nNothing was deployed and no new site was created.",
1663
+ { siteId: existing.siteId },
1664
+ "blocked"
1665
+ );
1666
+ }
1605
1667
  if (!isIncrementalReuseFailure(e)) throw e;
1606
1668
  update = await updateOnce(true);
1607
1669
  }
@@ -1952,13 +2014,17 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
1952
2014
  const res = await ctx.client.completeRecovery(args.verificationId, {
1953
2015
  ...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
1954
2016
  });
1955
- writeSiteFile(ctx.projectDir, {
1956
- siteId: res.siteId,
1957
- ...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
1958
- credential: res.credential,
1959
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1960
- apiBaseUrl: ctx.apiBaseUrl
1961
- });
2017
+ writeSiteFile(
2018
+ ctx.projectDir,
2019
+ {
2020
+ siteId: res.siteId,
2021
+ ...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
2022
+ credential: res.credential,
2023
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2024
+ apiBaseUrl: ctx.apiBaseUrl
2025
+ },
2026
+ { allowReplace: true }
2027
+ );
1962
2028
  return text(
1963
2029
  "domain_recovery_completed",
1964
2030
  `Recovery complete.
@@ -2035,7 +2101,8 @@ ${res.archiveUrl}`,
2035
2101
  },
2036
2102
  async (args) => {
2037
2103
  try {
2038
- const site = readSiteFile(ctx.projectDir);
2104
+ const siteState = loadSiteFile(ctx.projectDir);
2105
+ const site = siteState.kind === "ok" ? siteState.file : null;
2039
2106
  const diagnostics = {
2040
2107
  toolName: args.toolName,
2041
2108
  ...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
@@ -2358,7 +2425,7 @@ export {
2358
2425
  createSakupaMcpServer,
2359
2426
  deleteSiteFile,
2360
2427
  loadMcpRuntimeConfig,
2361
- readSiteFile,
2428
+ loadSiteFile,
2362
2429
  registerLifecycleTools,
2363
2430
  registerTools,
2364
2431
  requireSiteFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakupa/mcp",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
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",