@sakupa/mcp 0.7.6 → 0.7.8

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 +273 -146
  2. package/dist/index.js +274 -145
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -10,6 +10,7 @@ var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
10
10
  var TEST_ACCESS_HEADER = "x-sakupa-test-token";
11
11
  var FREE_SITE_TTL_HOURS = 24;
12
12
  var FREE_SITE_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
13
+ var FREE_ACTIVE_SITES_PER_IP = 3;
13
14
  var PAID_SITE_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
14
15
  var MAX_FILE_COUNT = 5e3;
15
16
  var MAX_SINGLE_FILE_BYTES = 25 * 1024 * 1024;
@@ -128,7 +129,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
128
129
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
129
130
 
130
131
  // ../core/dist/domain/version.js
131
- var SAKUPA_MCP_VERSION = "0.7.6";
132
+ var SAKUPA_MCP_VERSION = "0.7.8";
132
133
 
133
134
  // ../core/dist/domain/errors.js
134
135
  var HTTP_STATUS = {
@@ -407,22 +408,13 @@ function validateDeployableFiles(files, opts) {
407
408
  severity: "warning",
408
409
  code: "missing_html_lang",
409
410
  path: entryHtmlPath,
410
- message: 'The entry HTML has no lang attribute. Add html lang="en" | "ja" | "zh-CN" so Sakupa surfaces match the site language.'
411
+ message: 'The entry HTML has no lang attribute. Before deploying, add one matching the content language (html lang="en" | "ja" | "zh-CN") \u2014 it drives screen-reader pronunciation and search-engine language detection, and Sakupa surfaces follow it.'
411
412
  });
412
413
  }
413
414
  }
414
415
  }
415
416
  const jsCount = files.filter((f) => ["js", "mjs"].includes(fileExtension(f.path))).length;
416
417
  const looksLikeSpa = htmlPaths.length === 1 && entryHtmlPath === "index.html" && jsCount > 0;
417
- const wantsSpa = opts.spaFallbackRequested === true || looksLikeSpa;
418
- const spaFallbackConfirmationRequired = wantsSpa && opts.spaFallbackConfirmed !== true;
419
- if (opts.spaFallbackRequested === true && opts.spaFallbackConfirmed !== true) {
420
- issues.push({
421
- severity: "error",
422
- code: "spa_fallback_confirmation_required",
423
- message: "SPA fallback rewrites unknown paths to index.html and changes normal 404 behavior. It must be explicitly confirmed."
424
- });
425
- }
426
418
  const ok = issues.every((i) => i.severity !== "error");
427
419
  return {
428
420
  ok,
@@ -432,8 +424,7 @@ function validateDeployableFiles(files, opts) {
432
424
  entryHtmlPath,
433
425
  htmlLang,
434
426
  supportedLang,
435
- looksLikeSpa,
436
- spaFallbackConfirmationRequired
427
+ looksLikeSpa
437
428
  };
438
429
  }
439
430
  function safeDecode(bytes) {
@@ -690,8 +681,8 @@ var HttpApiClient = class {
690
681
  // src/tools/definitions.ts
691
682
  import { randomUUID } from "node:crypto";
692
683
  import { promises as fs2 } from "node:fs";
693
- import { join as join3, resolve as resolve2 } from "node:path";
694
- import { z as z2 } from "zod";
684
+ import { join as join4, resolve as resolve3 } from "node:path";
685
+ import { z as z3 } from "zod";
695
686
 
696
687
  // src/analyze/analyzer.ts
697
688
  import { promises as fs } from "node:fs";
@@ -1052,7 +1043,7 @@ async function analyzeProject(projectDir, opts = {}) {
1052
1043
  fileCount: 0,
1053
1044
  issues: [],
1054
1045
  ssrRisks,
1055
- spa: { looksLikeSpa: false, fallbackRecommended: false, confirmationRequired: false },
1046
+ spa: { looksLikeSpa: false, autoFallback: false },
1056
1047
  deployable: false,
1057
1048
  suggestedNextAction: suggestedNextAction2
1058
1049
  };
@@ -1072,30 +1063,23 @@ async function analyzeProject(projectDir, opts = {}) {
1072
1063
  }
1073
1064
  candidates.push({ path: file.path, size: file.size, ...content ? { content } : {} });
1074
1065
  }
1075
- const validation = validateDeployableFiles(candidates, {
1076
- mode: "free",
1077
- ...opts.spaFallbackRequested !== void 0 ? { spaFallbackRequested: opts.spaFallbackRequested } : {},
1078
- ...opts.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: opts.spaFallbackConfirmed } : {}
1079
- });
1066
+ const validation = validateDeployableFiles(candidates, { mode: "free" });
1080
1067
  ssrRisks.push(...serverAndDbDepRisks(pkg, true));
1081
1068
  const deployable = validation.ok && walked.length > 0;
1082
1069
  const spa = {
1083
1070
  looksLikeSpa: validation.looksLikeSpa,
1084
- fallbackRecommended: validation.looksLikeSpa,
1085
- confirmationRequired: validation.spaFallbackConfirmationRequired
1071
+ autoFallback: validation.looksLikeSpa
1086
1072
  };
1087
1073
  let suggestedNextAction;
1088
1074
  if (!deployable) {
1089
1075
  const firstError = validation.issues.find((i) => i.severity === "error");
1090
1076
  if (firstError?.code === "missing_index_html") {
1091
1077
  suggestedNextAction = `No index.html at the root of "${outputDirRel}". Deploy the built static output (the directory whose root contains index.html), not the source project. Build locally first if needed (${buildCommandHint ?? "npm run build"}), then re-run analyze_site.`;
1092
- } else if (firstError?.code === "spa_fallback_confirmation_required") {
1093
- suggestedNextAction = "SPA fallback rewrites unknown paths to index.html and changes normal 404 behavior. Confirm it explicitly: re-run with spaFallbackRequested: true and spaFallbackConfirmed: true.";
1094
1078
  } else {
1095
1079
  suggestedNextAction = "Fix the listed issues (remove forbidden/secret files, reduce size, add missing entry HTML), then re-run analyze_site.";
1096
1080
  }
1097
- } else if (spa.confirmationRequired) {
1098
- suggestedNextAction = `The output in "${outputDirRel}" is deployable, but it looks like a single-page app. Decide about SPA fallback first: run deploy_site with spaFallback: true and spaFallbackConfirmed: true to enable it, or with spaFallbackConfirmed: true alone to deploy without fallback.`;
1081
+ } else if (spa.looksLikeSpa) {
1082
+ suggestedNextAction = `Run deploy_site to publish the static output in "${outputDirRel}". It looks like a single-page app, so SPA fallback (unknown paths rewrite to index.html) will be enabled automatically; pass spaFallback: false to opt out.`;
1099
1083
  } else {
1100
1084
  suggestedNextAction = `Run deploy_site to publish the static output in "${outputDirRel}".`;
1101
1085
  }
@@ -1220,10 +1204,60 @@ function credentialGitReminder(projectDir) {
1220
1204
  return '\nNOTE: this project is inside a git repository. The management credential in .sakupa/site.json is the key to this site \u2014 do NOT commit it to a PUBLIC repository (add ".sakupa/" to .gitignore yourself if you want to keep it out of version control).';
1221
1205
  }
1222
1206
 
1207
+ // src/creation-registry.ts
1208
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1209
+ import { homedir } from "node:os";
1210
+ import { dirname as dirname2, join as join3 } from "node:path";
1211
+ var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
1212
+ function creationRegistryPath() {
1213
+ const base = process.env["SAKUPA_STATE_DIR"] ?? homedir();
1214
+ return join3(base, ".sakupa", "created-sites.json");
1215
+ }
1216
+ function readAll() {
1217
+ const path = creationRegistryPath();
1218
+ if (!existsSync2(path)) return [];
1219
+ try {
1220
+ const parsed = JSON.parse(readFileSync2(path, "utf-8"));
1221
+ if (!Array.isArray(parsed)) return [];
1222
+ return parsed.filter(
1223
+ (e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
1224
+ );
1225
+ } catch {
1226
+ return [];
1227
+ }
1228
+ }
1229
+ function writeAll(records) {
1230
+ const path = creationRegistryPath();
1231
+ mkdirSync2(dirname2(path), { recursive: true });
1232
+ writeFileSync2(path, `${JSON.stringify(records, null, 2)}
1233
+ `, "utf-8");
1234
+ }
1235
+ function listRecentCreations(nowMs) {
1236
+ return readAll().filter((e) => {
1237
+ const t = Date.parse(e.createdAt);
1238
+ return Number.isFinite(t) && nowMs - t < RECENT_WINDOW_MS;
1239
+ });
1240
+ }
1241
+ function recordCreation(record) {
1242
+ const rest = readAll().filter((e) => e.siteId !== record.siteId);
1243
+ writeAll([...rest, record]);
1244
+ }
1245
+ function removeCreation(siteId) {
1246
+ const all = readAll();
1247
+ const rest = all.filter((e) => e.siteId !== siteId);
1248
+ if (rest.length !== all.length) writeAll(rest);
1249
+ }
1250
+
1223
1251
  // src/version.ts
1224
1252
  var MCP_VERSION = SAKUPA_MCP_VERSION;
1225
1253
  var CLIENT_TYPE = "sakupa-mcp";
1226
1254
 
1255
+ // src/tools/context.ts
1256
+ import { z as z2 } from "zod";
1257
+ import { statSync } from "node:fs";
1258
+ import { homedir as homedir2 } from "node:os";
1259
+ import { isAbsolute, parse, resolve as resolve2 } from "node:path";
1260
+
1227
1261
  // src/tools/result.ts
1228
1262
  import { z } from "zod";
1229
1263
  var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
@@ -1266,22 +1300,64 @@ function structuredToolResult(envelope) {
1266
1300
  }
1267
1301
 
1268
1302
  // src/tools/context.ts
1303
+ var LocalGuidanceError = class extends SakupaError {
1304
+ constructor(code, message) {
1305
+ super(code, message);
1306
+ }
1307
+ };
1308
+ var projectDirInput = z2.string().optional().describe(
1309
+ "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."
1310
+ );
1311
+ function withProjectDir(ctx, projectDirArg) {
1312
+ if (projectDirArg === void 0) return ctx;
1313
+ if (!isAbsolute(projectDirArg)) {
1314
+ throw new LocalGuidanceError(
1315
+ "invalid_request",
1316
+ `projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
1317
+ );
1318
+ }
1319
+ const dir = resolve2(projectDirArg);
1320
+ if (parse(dir).root === dir || dir === homedir2()) {
1321
+ throw new LocalGuidanceError(
1322
+ "invalid_request",
1323
+ `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.`
1324
+ );
1325
+ }
1326
+ const stat = statSync(dir, { throwIfNoEntry: false });
1327
+ if (!stat?.isDirectory()) {
1328
+ throw new LocalGuidanceError(
1329
+ "invalid_request",
1330
+ `projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
1331
+ );
1332
+ }
1333
+ return { ...ctx, projectDir: dir };
1334
+ }
1269
1335
  function requireSiteFile(ctx) {
1270
1336
  const state = loadSiteFile(ctx.projectDir);
1271
1337
  if (state.kind === "corrupted") {
1272
- throw new SakupaError(
1338
+ throw new LocalGuidanceError(
1273
1339
  "invalid_request",
1274
1340
  `.sakupa/site.json in ${ctx.projectDir} is damaged: ${state.problem}. ` + siteFileRecoveryGuidance(ctx.projectDir)
1275
1341
  );
1276
1342
  }
1277
1343
  if (state.kind === "absent") {
1278
- throw new SakupaError(
1344
+ throw new LocalGuidanceError(
1279
1345
  "not_found",
1280
1346
  `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.`
1281
1347
  );
1282
1348
  }
1283
1349
  return state.file;
1284
1350
  }
1351
+ var STATIC_SUMMARY = {
1352
+ not_found: "The required local project binding or resource is unavailable; if this project has no .sakupa/site.json yet, run deploy_site first.",
1353
+ 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.",
1354
+ invalid_request: "The request arguments or local project checks did not pass.",
1355
+ validation_failed: "The request arguments or local project checks did not pass.",
1356
+ state_conflict: "The resource state has changed; re-query the current status before deciding the next step.",
1357
+ confirmation_required: "The site or billing state changed, so the previous confirmation is stale; run the preview again and confirm against the fresh snapshot.",
1358
+ payment_required: "This operation requires an active subscription; check billing_status first.",
1359
+ rate_limited: "The server rate limit was reached; retry after the returned wait time."
1360
+ };
1285
1361
  function toolError(e) {
1286
1362
  const errorCode = isSakupaError(e) ? e.code : "internal";
1287
1363
  const retryable = errorCode === "rate_limited" || errorCode === "internal";
@@ -1300,7 +1376,7 @@ function toolError(e) {
1300
1376
  )
1301
1377
  ) : void 0;
1302
1378
  const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
1303
- 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.";
1379
+ 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.");
1304
1380
  const result = structuredToolResult({
1305
1381
  schemaVersion: 1,
1306
1382
  outcome: "failed",
@@ -1339,12 +1415,12 @@ ${JSON.stringify(obj, null, 2)}`;
1339
1415
  nextActions: []
1340
1416
  });
1341
1417
  }
1342
- var planEnum = z2.enum(["water", "personal", "share", "business"]);
1343
- var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
1418
+ var planEnum = z3.enum(["water", "personal", "share", "business"]);
1419
+ var severityEnum = z3.enum(["low", "medium", "high", "critical"]);
1344
1420
  function planCatalog() {
1345
- return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
1421
+ return TIER_ORDER.map((p) => `${p} JPY ${tierPriceJpy(p)}/month`).join(", ");
1346
1422
  }
1347
- var ticketCategoryEnum = z2.enum([
1423
+ var ticketCategoryEnum = z3.enum([
1348
1424
  "billing",
1349
1425
  "payment",
1350
1426
  "refund_review",
@@ -1370,22 +1446,6 @@ Analysis:`,
1370
1446
  "blocked"
1371
1447
  );
1372
1448
  }
1373
- function spaConfirmationResult(analysis) {
1374
- return text(
1375
- "spa_fallback_confirmation_required",
1376
- `SPA fallback confirmation required \u2014 nothing was deployed yet.
1377
-
1378
- This site looks like a single-page application (one index.html plus JavaScript). SPA fallback rewrites every unknown path to index.html so client-side routes work, but it CHANGES normal 404 behavior: visitors never see a not-found page.
1379
-
1380
- Please ask the user to choose, then re-run deploy_site with:
1381
- - spaFallback: true, spaFallbackConfirmed: true -> enable SPA fallback
1382
- - spaFallbackConfirmed: true (spaFallback omitted or false) -> deploy WITHOUT fallback (unknown paths return 404)
1383
-
1384
- Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`,
1385
- { analysis: analysisSummary(analysis), requestedConfirmation: "spa_fallback" },
1386
- "waiting_user"
1387
- );
1388
- }
1389
1449
  var MB2 = 1024 * 1024;
1390
1450
  function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
1391
1451
  const oversized = manifest.find((f) => f.size > MAX_SINGLE_FILE_BYTES);
@@ -1408,7 +1468,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
1408
1468
  async function buildHashedManifest(files, outputAbs) {
1409
1469
  const manifest = [];
1410
1470
  for (const file of files) {
1411
- const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, file.path)));
1471
+ const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, file.path)));
1412
1472
  manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
1413
1473
  }
1414
1474
  return manifest;
@@ -1427,7 +1487,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1427
1487
  `No local file matches upload target "${target.path}"; aborting upload.`
1428
1488
  );
1429
1489
  }
1430
- const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, match.path)));
1490
+ const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, match.path)));
1431
1491
  if (bytes.byteLength !== match.size) {
1432
1492
  throw new SakupaError(
1433
1493
  "validation_failed",
@@ -1438,8 +1498,23 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1438
1498
  }
1439
1499
  return targets.length;
1440
1500
  }
1441
- function registerTools(server, ctx) {
1442
- const previewHostPattern = previewHostPatternFor(ctx.apiBaseUrl);
1501
+ function freeSiteCreationBarrier() {
1502
+ const recent = listRecentCreations(Date.now());
1503
+ if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
1504
+ const registryPath = creationRegistryPath();
1505
+ return text(
1506
+ "local_site_limit_reached",
1507
+ `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.
1508
+
1509
+ ` + recent.map((r) => `- ${r.url} (project: ${r.projectDir}, created: ${r.createdAt})`).join("\n") + `
1510
+
1511
+ 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.`,
1512
+ { recentCreations: recent, limit: FREE_ACTIVE_SITES_PER_IP, registryPath },
1513
+ "blocked"
1514
+ );
1515
+ }
1516
+ function registerTools(server, baseCtx) {
1517
+ const previewHostPattern = previewHostPatternFor(baseCtx.apiBaseUrl);
1443
1518
  server.registerTool(
1444
1519
  "analyze_site",
1445
1520
  {
@@ -1447,17 +1522,15 @@ function registerTools(server, ctx) {
1447
1522
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1448
1523
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1449
1524
  inputSchema: {
1450
- outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1451
- spaFallbackRequested: z2.boolean().optional().describe("User asked for SPA fallback (unknown paths rewritten to index.html)."),
1452
- spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change.")
1525
+ projectDir: projectDirInput,
1526
+ outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection).")
1453
1527
  }
1454
1528
  },
1455
1529
  async (args) => {
1456
1530
  try {
1531
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1457
1532
  const analysis = await analyzeProject(ctx.projectDir, {
1458
- ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
1459
- ...args.spaFallbackRequested !== void 0 ? { spaFallbackRequested: args.spaFallbackRequested } : {},
1460
- ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1533
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
1461
1534
  });
1462
1535
  return textJson(
1463
1536
  "site_analysis_completed",
@@ -1477,30 +1550,28 @@ Next action: ${analysis.suggestedNextAction}`,
1477
1550
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1478
1551
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1479
1552
  inputSchema: {
1480
- outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1481
- spaFallback: z2.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
1482
- spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
1483
- publicConfirmed: z2.boolean().optional().describe(
1553
+ projectDir: projectDirInput,
1554
+ outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1555
+ spaFallback: z3.boolean().optional().describe(
1556
+ "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."
1557
+ ),
1558
+ publicConfirmed: z3.boolean().optional().describe(
1484
1559
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1485
1560
  ),
1486
- lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1561
+ lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1487
1562
  }
1488
1563
  },
1489
1564
  async (args) => {
1490
1565
  try {
1566
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1491
1567
  const analysis = await analyzeProject(ctx.projectDir, {
1492
- ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
1493
- ...args.spaFallback !== void 0 ? { spaFallbackRequested: args.spaFallback } : {},
1494
- ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1568
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
1495
1569
  });
1496
- if (analysis.spa.confirmationRequired && args.spaFallbackConfirmed !== true) {
1497
- return spaConfirmationResult(analysis);
1498
- }
1499
1570
  if (!analysis.deployable || !analysis.files) {
1500
1571
  return notDeployableResult(analysis);
1501
1572
  }
1502
1573
  const files = analysis.files;
1503
- const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1574
+ const outputAbs = resolve3(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1504
1575
  const manifest = await buildHashedManifest(files, outputAbs);
1505
1576
  const siteFileState = loadSiteFile(ctx.projectDir);
1506
1577
  if (siteFileState.kind === "corrupted") {
@@ -1514,6 +1585,10 @@ Next action: ${analysis.suggestedNextAction}`,
1514
1585
  );
1515
1586
  }
1516
1587
  const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
1588
+ if (!existing) {
1589
+ const barrier = freeSiteCreationBarrier();
1590
+ if (barrier) return barrier;
1591
+ }
1517
1592
  if (!existing && args.publicConfirmed !== true) {
1518
1593
  return text(
1519
1594
  "public_deployment_confirmation_required",
@@ -1527,8 +1602,7 @@ Next action: ${analysis.suggestedNextAction}`,
1527
1602
  const created = await ctx.client.createSite({
1528
1603
  manifest,
1529
1604
  ...args.lang !== void 0 ? { lang: args.lang } : {},
1530
- spaFallback: args.spaFallback === true,
1531
- ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1605
+ ...args.spaFallback !== void 0 ? { spaFallback: args.spaFallback } : {}
1532
1606
  });
1533
1607
  const uploaded2 = await uploadAll(ctx, created.uploadTargets, files, outputAbs);
1534
1608
  const finalized2 = await ctx.client.finalizeDeployment(
@@ -1536,17 +1610,25 @@ Next action: ${analysis.suggestedNextAction}`,
1536
1610
  created.siteId,
1537
1611
  created.credential
1538
1612
  );
1613
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1539
1614
  writeSiteFile(ctx.projectDir, {
1540
1615
  siteId: created.siteId,
1541
1616
  shortId: created.shortId,
1542
1617
  url: finalized2.url,
1543
1618
  credential: created.credential,
1544
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1619
+ createdAt,
1545
1620
  apiBaseUrl: ctx.apiBaseUrl
1546
1621
  });
1622
+ recordCreation({
1623
+ siteId: created.siteId,
1624
+ projectDir: ctx.projectDir,
1625
+ url: finalized2.url,
1626
+ createdAt
1627
+ });
1547
1628
  return text(
1548
1629
  "site_published",
1549
1630
  `Site published: ${finalized2.url}
1631
+ Project directory: ${ctx.projectDir}
1550
1632
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1551
1633
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
1552
1634
  ` : "") + `
@@ -1564,7 +1646,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1564
1646
  filesUploaded: uploaded2,
1565
1647
  totalBytes: finalized2.totalBytes,
1566
1648
  warnings: finalized2.warnings,
1567
- credentialStoredLocally: true
1649
+ credentialStoredLocally: true,
1650
+ projectDir: ctx.projectDir
1568
1651
  }
1569
1652
  );
1570
1653
  }
@@ -1572,8 +1655,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1572
1655
  const req = {
1573
1656
  manifest,
1574
1657
  ...args.lang !== void 0 ? { lang: args.lang } : {},
1575
- spaFallback: args.spaFallback === true,
1576
- ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {},
1658
+ ...args.spaFallback !== void 0 ? { spaFallback: args.spaFallback } : {},
1577
1659
  ...forceFullUpload ? { forceFullUpload: true } : {}
1578
1660
  };
1579
1661
  const deployment = await ctx.client.createDeployment(
@@ -1611,6 +1693,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1611
1693
  return text(
1612
1694
  "site_updated",
1613
1695
  `Site updated: ${finalized.url}
1696
+ Project directory: ${ctx.projectDir}
1614
1697
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1615
1698
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
1616
1699
  ` : "") + (finalized.mode === "free" ? `
@@ -1622,6 +1705,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1622
1705
  siteId: existing.siteId,
1623
1706
  url: finalized.url,
1624
1707
  mode: finalized.mode,
1708
+ projectDir: ctx.projectDir,
1625
1709
  expiresAt: finalized.expiresAt,
1626
1710
  filesUploaded: uploaded,
1627
1711
  totalBytes: finalized.totalBytes,
@@ -1639,17 +1723,18 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1639
1723
  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.",
1640
1724
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1641
1725
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1642
- inputSchema: {}
1726
+ inputSchema: { projectDir: projectDirInput }
1643
1727
  },
1644
- async () => {
1728
+ async (args) => {
1645
1729
  try {
1730
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1646
1731
  const site = requireSiteFile(ctx);
1647
1732
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
1648
1733
  return text(
1649
1734
  "site_refreshed",
1650
- `Site validity refreshed. New expiry: ${res.expiresAt}
1651
- Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`,
1652
- { siteId: site.siteId, expiresAt: res.expiresAt }
1735
+ `Site validity refreshed (project: ${ctx.projectDir}). New expiry: ${res.expiresAt}
1736
+ 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.`,
1737
+ { siteId: site.siteId, expiresAt: res.expiresAt, projectDir: ctx.projectDir }
1653
1738
  );
1654
1739
  } catch (e) {
1655
1740
  return toolError(e);
@@ -1662,13 +1747,17 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1662
1747
  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.",
1663
1748
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1664
1749
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1665
- inputSchema: {}
1750
+ inputSchema: { projectDir: projectDirInput }
1666
1751
  },
1667
- async () => {
1752
+ async (args) => {
1668
1753
  try {
1754
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1669
1755
  const site = requireSiteFile(ctx);
1670
1756
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
1671
- return textJson("site_status_returned", "Site status:", res);
1757
+ return textJson("site_status_returned", "Site status:", {
1758
+ ...res,
1759
+ projectDir: ctx.projectDir
1760
+ });
1672
1761
  } catch (e) {
1673
1762
  return toolError(e);
1674
1763
  }
@@ -1681,6 +1770,7 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1681
1770
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1682
1771
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1683
1772
  inputSchema: {
1773
+ projectDir: projectDirInput,
1684
1774
  plan: planEnum.describe(
1685
1775
  "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
1686
1776
  )
@@ -1688,6 +1778,7 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1688
1778
  },
1689
1779
  async (args) => {
1690
1780
  try {
1781
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1691
1782
  const site = requireSiteFile(ctx);
1692
1783
  const res = await ctx.client.createPlanCheckout(
1693
1784
  {
@@ -1699,7 +1790,7 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1699
1790
  );
1700
1791
  return text(
1701
1792
  "subscription_checkout_ready",
1702
- `Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
1793
+ `Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, JPY ${res.monthlyPriceJpy}/month (Japanese yen)
1703
1794
  ${res.checkoutUrl}
1704
1795
 
1705
1796
  Open this link in a browser to subscribe. Card data is entered only on the Stripe-hosted page \u2014 never give card numbers, passwords or security codes to the AI tool.
@@ -1726,13 +1817,15 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1726
1817
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1727
1818
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1728
1819
  inputSchema: {
1729
- action: z2.enum(["start", "status"]),
1730
- hostname: z2.string().optional().describe("Required for start."),
1731
- verificationId: z2.string().optional().describe("Required for status.")
1820
+ projectDir: projectDirInput,
1821
+ action: z3.enum(["start", "status"]),
1822
+ hostname: z3.string().optional().describe("Required for start."),
1823
+ verificationId: z3.string().optional().describe("Required for status.")
1732
1824
  }
1733
1825
  },
1734
1826
  async (args) => {
1735
1827
  try {
1828
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1736
1829
  const site = requireSiteFile(ctx);
1737
1830
  if (args.action === "status") {
1738
1831
  if (!args.verificationId) {
@@ -1802,16 +1895,17 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
1802
1895
  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).",
1803
1896
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1804
1897
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1805
- inputSchema: {}
1898
+ inputSchema: { projectDir: projectDirInput }
1806
1899
  },
1807
- async () => {
1900
+ async (args) => {
1808
1901
  try {
1902
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1809
1903
  const site = requireSiteFile(ctx);
1810
1904
  const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
1811
1905
  const lines = [
1812
1906
  `Billing status for site ${res.siteId} (mode: ${res.mode})`,
1813
1907
  res.permanentUrl ? `Permanent URL: ${res.permanentUrl}` : void 0,
1814
- res.plan ? `Plan: ${res.plan} (\xA5${res.monthlyPriceJpy ?? tierPriceJpy(res.plan)}/month)` : "Plan: (no subscription yet)",
1908
+ res.plan ? `Plan: ${res.plan} (JPY ${res.monthlyPriceJpy ?? tierPriceJpy(res.plan)}/month)` : "Plan: (no subscription yet)",
1815
1909
  res.subscriptionStatus ? `Subscription payment state: ${res.subscriptionStatus}` : void 0,
1816
1910
  res.cancelAtPeriodEnd ? "Renewal: CANCELED \u2014 the site reverts to free at the end of the already-paid month" : void 0,
1817
1911
  res.currentPeriodStart ? `Current paid period: ${res.currentPeriodStart} -> ${res.currentPeriodEnd ?? "?"}` : void 0,
@@ -1835,11 +1929,13 @@ Full status:`, res);
1835
1929
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1836
1930
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1837
1931
  inputSchema: {
1838
- scope: z2.enum(["site", "public_recovery"])
1932
+ projectDir: projectDirInput,
1933
+ scope: z3.enum(["site", "public_recovery"])
1839
1934
  }
1840
1935
  },
1841
1936
  async (args) => {
1842
1937
  try {
1938
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1843
1939
  if (args.scope === "site") {
1844
1940
  const site = requireSiteFile(ctx);
1845
1941
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
@@ -1890,14 +1986,16 @@ Full status:`, res);
1890
1986
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1891
1987
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1892
1988
  inputSchema: {
1893
- action: z2.enum(["start", "status", "complete"]),
1894
- hostname: z2.string().optional().describe("Required for start."),
1895
- verificationId: z2.string().optional().describe("Required for status or complete."),
1896
- preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
1989
+ projectDir: projectDirInput,
1990
+ action: z3.enum(["start", "status", "complete"]),
1991
+ hostname: z3.string().optional().describe("Required for start."),
1992
+ verificationId: z3.string().optional().describe("Required for status or complete."),
1993
+ preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
1897
1994
  }
1898
1995
  },
1899
1996
  async (args) => {
1900
1997
  try {
1998
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1901
1999
  if (args.action === "start") {
1902
2000
  if (!args.hostname) {
1903
2001
  throw new SakupaError("invalid_request", "hostname is required for start");
@@ -1995,14 +2093,16 @@ ${res.archiveUrl}`,
1995
2093
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1996
2094
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1997
2095
  inputSchema: {
2096
+ projectDir: projectDirInput,
1998
2097
  category: ticketCategoryEnum,
1999
- subject: z2.string().describe("Short subject line."),
2000
- description: z2.string().describe("Problem description (no secrets, no card data)."),
2001
- contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
2098
+ subject: z3.string().describe("Short subject line."),
2099
+ description: z3.string().describe("Problem description (no secrets, no card data)."),
2100
+ contactEmail: z3.string().optional().describe("Optional contact email for follow-up.")
2002
2101
  }
2003
2102
  },
2004
2103
  async (args) => {
2005
2104
  try {
2105
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2006
2106
  const site = requireSiteFile(ctx);
2007
2107
  const res = await ctx.client.createTicket(site.credential, {
2008
2108
  siteId: site.siteId,
@@ -2028,18 +2128,20 @@ ${res.archiveUrl}`,
2028
2128
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2029
2129
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2030
2130
  inputSchema: {
2031
- toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
2032
- errorCode: z2.string().optional(),
2033
- errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
2034
- requestId: z2.string().optional(),
2035
- deploymentId: z2.string().optional(),
2131
+ projectDir: projectDirInput,
2132
+ toolName: z3.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
2133
+ errorCode: z3.string().optional(),
2134
+ errorMessage: z3.string().optional().describe("Sanitized error message (no secrets)."),
2135
+ requestId: z3.string().optional(),
2136
+ deploymentId: z3.string().optional(),
2036
2137
  severity: severityEnum.optional(),
2037
- description: z2.string().optional().describe("What happened, in the user's words (no secrets)."),
2038
- confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
2138
+ description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
2139
+ confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
2039
2140
  }
2040
2141
  },
2041
2142
  async (args) => {
2042
2143
  try {
2144
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2043
2145
  const siteState = loadSiteFile(ctx.projectDir);
2044
2146
  const site = siteState.kind === "ok" ? siteState.file : null;
2045
2147
  const diagnostics = {
@@ -2083,19 +2185,20 @@ Summary: ${res.sanitizedSummary}`,
2083
2185
  }
2084
2186
 
2085
2187
  // src/tools/billing.ts
2086
- import { z as z3 } from "zod";
2087
- var plan = z3.enum(["water", "personal", "share", "business"]);
2088
- function registerBillingTools(server, ctx) {
2188
+ import { z as z4 } from "zod";
2189
+ var plan = z4.enum(["water", "personal", "share", "business"]);
2190
+ function registerBillingTools(server, baseCtx) {
2089
2191
  server.registerTool(
2090
2192
  "list_billing_plans",
2091
2193
  {
2092
2194
  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.",
2093
- inputSchema: {},
2195
+ inputSchema: { projectDir: projectDirInput },
2094
2196
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2095
2197
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
2096
2198
  },
2097
- async () => {
2199
+ async (args) => {
2098
2200
  try {
2201
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2099
2202
  const catalog = await ctx.client.getBillingPlanCatalog();
2100
2203
  return structuredToolResult({
2101
2204
  schemaVersion: 1,
@@ -2115,14 +2218,16 @@ function registerBillingTools(server, ctx) {
2115
2218
  {
2116
2219
  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.",
2117
2220
  inputSchema: {
2221
+ projectDir: projectDirInput,
2118
2222
  targetPlan: plan,
2119
- operationId: z3.string().min(1)
2223
+ operationId: z4.string().min(1)
2120
2224
  },
2121
2225
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2122
2226
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
2123
2227
  },
2124
2228
  async (args) => {
2125
2229
  try {
2230
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2126
2231
  const site = requireSiteFile(ctx);
2127
2232
  const result = await ctx.client.changeSubscriptionPlan(site.credential, {
2128
2233
  siteId: site.siteId,
@@ -2152,39 +2257,40 @@ function registerBillingTools(server, ctx) {
2152
2257
  }
2153
2258
 
2154
2259
  // src/tools/lifecycle.ts
2155
- import { z as z4 } from "zod";
2156
- var deleteConfirmation = z4.object({
2157
- siteId: z4.string().min(1),
2158
- expectedSiteUpdatedAt: z4.string().datetime(),
2159
- expectedStatus: z4.enum(["active", "expired", "deleted"]),
2160
- expectedMode: z4.enum(["free", "paid"]),
2161
- expectedServingMode: z4.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
2162
- expectedShortId: z4.string().optional(),
2163
- expectedSubscriptionStatus: z4.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
2164
- expectedPlan: z4.enum(["water", "personal", "share", "business"]).optional(),
2165
- expectedCancelAtPeriodEnd: z4.boolean().optional(),
2166
- expectedCurrentPeriodEnd: z4.string().datetime().optional(),
2167
- expectedLastDeploymentId: z4.string().optional(),
2168
- expectedBoundHostnames: z4.array(z4.string()),
2169
- acknowledge: z4.literal("delete_site_and_cancel_renewal")
2260
+ import { z as z5 } from "zod";
2261
+ var deleteConfirmation = z5.object({
2262
+ siteId: z5.string().min(1),
2263
+ expectedSiteUpdatedAt: z5.string().datetime(),
2264
+ expectedStatus: z5.enum(["active", "expired", "deleted"]),
2265
+ expectedMode: z5.enum(["free", "paid"]),
2266
+ expectedServingMode: z5.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
2267
+ expectedShortId: z5.string().optional(),
2268
+ expectedSubscriptionStatus: z5.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
2269
+ expectedPlan: z5.enum(["water", "personal", "share", "business"]).optional(),
2270
+ expectedCancelAtPeriodEnd: z5.boolean().optional(),
2271
+ expectedCurrentPeriodEnd: z5.string().datetime().optional(),
2272
+ expectedLastDeploymentId: z5.string().optional(),
2273
+ expectedBoundHostnames: z5.array(z5.string()),
2274
+ acknowledge: z5.literal("delete_site_and_cancel_renewal")
2170
2275
  });
2171
- var unbindConfirmation = z4.object({
2172
- siteId: z4.string().min(1),
2173
- bindingId: z4.string().min(1),
2174
- expectedBindingUpdatedAt: z4.string().datetime(),
2175
- expectedBindingStatus: z4.enum(["provisioning", "active"]),
2176
- apexDomain: z4.string().min(1),
2177
- expectedBoundHostnames: z4.array(z4.string()),
2178
- acknowledge: z4.literal("unbind_domain_and_remove_custom_hostnames")
2276
+ var unbindConfirmation = z5.object({
2277
+ siteId: z5.string().min(1),
2278
+ bindingId: z5.string().min(1),
2279
+ expectedBindingUpdatedAt: z5.string().datetime(),
2280
+ expectedBindingStatus: z5.enum(["provisioning", "active"]),
2281
+ apexDomain: z5.string().min(1),
2282
+ expectedBoundHostnames: z5.array(z5.string()),
2283
+ acknowledge: z5.literal("unbind_domain_and_remove_custom_hostnames")
2179
2284
  });
2180
- function registerLifecycleTools(server, ctx) {
2285
+ function registerLifecycleTools(server, baseCtx) {
2181
2286
  server.registerTool(
2182
2287
  "delete_site",
2183
2288
  {
2184
2289
  description: "Preview or execute deletion of this Sakupa site. Execution requires an exact server-validated confirmation bound to the current site state.",
2185
2290
  inputSchema: {
2186
- action: z4.enum(["preview", "confirm"]),
2187
- operationId: z4.string().min(1).optional(),
2291
+ projectDir: projectDirInput,
2292
+ action: z5.enum(["preview", "confirm"]),
2293
+ operationId: z5.string().min(1).optional(),
2188
2294
  confirmation: deleteConfirmation.optional()
2189
2295
  },
2190
2296
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -2192,6 +2298,7 @@ function registerLifecycleTools(server, ctx) {
2192
2298
  },
2193
2299
  async (args) => {
2194
2300
  try {
2301
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2195
2302
  const site = requireSiteFile(ctx);
2196
2303
  if (!args.operationId) {
2197
2304
  throw new Error("operationId is required for delete_site");
@@ -2218,13 +2325,14 @@ function registerLifecycleTools(server, ctx) {
2218
2325
  confirmation: args.confirmation
2219
2326
  });
2220
2327
  deleteSiteFile(ctx.projectDir);
2328
+ removeCreation(site.siteId);
2221
2329
  return structuredToolResult({
2222
2330
  schemaVersion: 1,
2223
2331
  outcome: result.servingDeletionPending ? "pending_provider" : "completed",
2224
2332
  resultCode: "site_deleted",
2225
2333
  operationId: args.operationId,
2226
- summary: "Site deleted; the local management credential file was removed.",
2227
- data: { result },
2334
+ summary: `Site deleted; the local management credential file was removed from ${ctx.projectDir}.`,
2335
+ data: { result, projectDir: ctx.projectDir },
2228
2336
  nextActions: []
2229
2337
  });
2230
2338
  } catch (error) {
@@ -2237,8 +2345,9 @@ function registerLifecycleTools(server, ctx) {
2237
2345
  {
2238
2346
  description: "Preview or execute removal of the custom apex/www serving surface while preserving the subscription and permanent Sakupa URL.",
2239
2347
  inputSchema: {
2240
- action: z4.enum(["preview", "confirm"]),
2241
- operationId: z4.string().min(1).optional(),
2348
+ projectDir: projectDirInput,
2349
+ action: z5.enum(["preview", "confirm"]),
2350
+ operationId: z5.string().min(1).optional(),
2242
2351
  confirmation: unbindConfirmation.optional()
2243
2352
  },
2244
2353
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -2246,6 +2355,7 @@ function registerLifecycleTools(server, ctx) {
2246
2355
  },
2247
2356
  async (args) => {
2248
2357
  try {
2358
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2249
2359
  const site = requireSiteFile(ctx);
2250
2360
  if (!args.operationId) throw new Error("operationId is required for unbind_domain");
2251
2361
  if (args.action === "preview") {
@@ -2276,7 +2386,7 @@ function registerLifecycleTools(server, ctx) {
2276
2386
  outcome: result.servingDeletionPending ? "pending_provider" : "completed",
2277
2387
  resultCode: "domain_unbound",
2278
2388
  operationId: args.operationId,
2279
- summary: "Custom domain unbound; the subscription, deployed content, and permanent Sakupa URL are unchanged.",
2389
+ summary: `Custom domain unbound; the subscription, deployed content, and permanent Sakupa URL are unchanged. (project: ${ctx.projectDir})`,
2280
2390
  data: { result },
2281
2391
  nextActions: [{ tool: "site_status", allowed: true }]
2282
2392
  });
@@ -2389,11 +2499,28 @@ Workflow:
2389
2499
  5. create_support_ticket (subscribed sites) opens a support ticket; report_bug sends a
2390
2500
  sanitized diagnostic report after the user explicitly confirms it.
2391
2501
 
2502
+ Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
2503
+ binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2504
+ the directory the user is currently working in, on every call. Without it the server falls
2505
+ back to its startup directory, which may be a different project than the one the user is
2506
+ looking at. analyze_site, deploy_site, site_status, refresh_site, delete_site and unbind_domain echo
2507
+ the directory they acted on \u2014 verify it matches the user's active project.
2508
+
2392
2509
  Safety boundaries:
2393
2510
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
2394
2511
  - Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
2395
2512
  - Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
2396
2513
  - A subscription never grants domain ownership; only DNS verification does.
2514
+ - Never repeat, echo, or memorize the credential value from .sakupa/site.json \u2014 quoting it
2515
+ into the conversation copies the site's only key outside the protected local file. Read it
2516
+ only through the tools.
2517
+ - Before deploying, if the entry HTML lacks a lang attribute, add one matching the content
2518
+ language (infer it from the content) and then deploy; only skip when the user explicitly
2519
+ wants no lang attribute.
2520
+ - Prices are authoritative in JPY (Japanese yen). When talking with a user in a language
2521
+ other than Japanese, look up the approximate exchange rate and show an estimated local
2522
+ price next to the JPY amount, clearly marked as an estimate \u2014 Stripe always settles the
2523
+ real charge in JPY. Never show a bare Yen sign.
2397
2524
  - The management credential lives only in .sakupa/site.json; never share or upload it. Without
2398
2525
  a bound custom domain, a lost credential is unrecoverable by design. manage_billing then opens
2399
2526
  Stripe's public no-code portal login, where the customer verifies the checkout email with a
@@ -2431,7 +2558,7 @@ async function main() {
2431
2558
  const transport = new StdioServerTransport();
2432
2559
  await server.connect(transport);
2433
2560
  console.error(
2434
- `[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}, project: ${config.projectDir})`
2561
+ `[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}, default project: ${config.projectDir}; tools accept per-call projectDir)`
2435
2562
  );
2436
2563
  }
2437
2564
  main().catch((err) => {