@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/index.js CHANGED
@@ -5,6 +5,7 @@ var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
5
5
  var TEST_ACCESS_HEADER = "x-sakupa-test-token";
6
6
  var FREE_SITE_TTL_HOURS = 24;
7
7
  var FREE_SITE_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
8
+ var FREE_ACTIVE_SITES_PER_IP = 3;
8
9
  var PAID_SITE_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
9
10
  var MAX_FILE_COUNT = 5e3;
10
11
  var MAX_SINGLE_FILE_BYTES = 25 * 1024 * 1024;
@@ -123,7 +124,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
123
124
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
124
125
 
125
126
  // ../core/dist/domain/version.js
126
- var SAKUPA_MCP_VERSION = "0.7.6";
127
+ var SAKUPA_MCP_VERSION = "0.7.8";
127
128
 
128
129
  // ../core/dist/domain/errors.js
129
130
  var HTTP_STATUS = {
@@ -402,22 +403,13 @@ function validateDeployableFiles(files, opts) {
402
403
  severity: "warning",
403
404
  code: "missing_html_lang",
404
405
  path: entryHtmlPath,
405
- message: 'The entry HTML has no lang attribute. Add html lang="en" | "ja" | "zh-CN" so Sakupa surfaces match the site language.'
406
+ 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.'
406
407
  });
407
408
  }
408
409
  }
409
410
  }
410
411
  const jsCount = files.filter((f) => ["js", "mjs"].includes(fileExtension(f.path))).length;
411
412
  const looksLikeSpa = htmlPaths.length === 1 && entryHtmlPath === "index.html" && jsCount > 0;
412
- const wantsSpa = opts.spaFallbackRequested === true || looksLikeSpa;
413
- const spaFallbackConfirmationRequired = wantsSpa && opts.spaFallbackConfirmed !== true;
414
- if (opts.spaFallbackRequested === true && opts.spaFallbackConfirmed !== true) {
415
- issues.push({
416
- severity: "error",
417
- code: "spa_fallback_confirmation_required",
418
- message: "SPA fallback rewrites unknown paths to index.html and changes normal 404 behavior. It must be explicitly confirmed."
419
- });
420
- }
421
413
  const ok = issues.every((i) => i.severity !== "error");
422
414
  return {
423
415
  ok,
@@ -427,8 +419,7 @@ function validateDeployableFiles(files, opts) {
427
419
  entryHtmlPath,
428
420
  htmlLang,
429
421
  supportedLang,
430
- looksLikeSpa,
431
- spaFallbackConfirmationRequired
422
+ looksLikeSpa
432
423
  };
433
424
  }
434
425
  function safeDecode(bytes) {
@@ -1215,7 +1206,7 @@ async function analyzeProject(projectDir, opts = {}) {
1215
1206
  fileCount: 0,
1216
1207
  issues: [],
1217
1208
  ssrRisks,
1218
- spa: { looksLikeSpa: false, fallbackRecommended: false, confirmationRequired: false },
1209
+ spa: { looksLikeSpa: false, autoFallback: false },
1219
1210
  deployable: false,
1220
1211
  suggestedNextAction: suggestedNextAction2
1221
1212
  };
@@ -1235,30 +1226,23 @@ async function analyzeProject(projectDir, opts = {}) {
1235
1226
  }
1236
1227
  candidates.push({ path: file.path, size: file.size, ...content ? { content } : {} });
1237
1228
  }
1238
- const validation = validateDeployableFiles(candidates, {
1239
- mode: "free",
1240
- ...opts.spaFallbackRequested !== void 0 ? { spaFallbackRequested: opts.spaFallbackRequested } : {},
1241
- ...opts.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: opts.spaFallbackConfirmed } : {}
1242
- });
1229
+ const validation = validateDeployableFiles(candidates, { mode: "free" });
1243
1230
  ssrRisks.push(...serverAndDbDepRisks(pkg, true));
1244
1231
  const deployable = validation.ok && walked.length > 0;
1245
1232
  const spa = {
1246
1233
  looksLikeSpa: validation.looksLikeSpa,
1247
- fallbackRecommended: validation.looksLikeSpa,
1248
- confirmationRequired: validation.spaFallbackConfirmationRequired
1234
+ autoFallback: validation.looksLikeSpa
1249
1235
  };
1250
1236
  let suggestedNextAction;
1251
1237
  if (!deployable) {
1252
1238
  const firstError = validation.issues.find((i) => i.severity === "error");
1253
1239
  if (firstError?.code === "missing_index_html") {
1254
1240
  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.`;
1255
- } else if (firstError?.code === "spa_fallback_confirmation_required") {
1256
- 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.";
1257
1241
  } else {
1258
1242
  suggestedNextAction = "Fix the listed issues (remove forbidden/secret files, reduce size, add missing entry HTML), then re-run analyze_site.";
1259
1243
  }
1260
- } else if (spa.confirmationRequired) {
1261
- 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.`;
1244
+ } else if (spa.looksLikeSpa) {
1245
+ 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.`;
1262
1246
  } else {
1263
1247
  suggestedNextAction = `Run deploy_site to publish the static output in "${outputDirRel}".`;
1264
1248
  }
@@ -1282,6 +1266,12 @@ async function analyzeProject(projectDir, opts = {}) {
1282
1266
  };
1283
1267
  }
1284
1268
 
1269
+ // src/tools/context.ts
1270
+ import { z as z2 } from "zod";
1271
+ import { statSync } from "node:fs";
1272
+ import { homedir } from "node:os";
1273
+ import { isAbsolute, parse, resolve as resolve2 } from "node:path";
1274
+
1285
1275
  // src/tools/result.ts
1286
1276
  import { z } from "zod";
1287
1277
  var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
@@ -1324,22 +1314,64 @@ function structuredToolResult(envelope) {
1324
1314
  }
1325
1315
 
1326
1316
  // src/tools/context.ts
1317
+ var LocalGuidanceError = class extends SakupaError {
1318
+ constructor(code, message) {
1319
+ super(code, message);
1320
+ }
1321
+ };
1322
+ var projectDirInput = z2.string().optional().describe(
1323
+ "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."
1324
+ );
1325
+ function withProjectDir(ctx, projectDirArg) {
1326
+ if (projectDirArg === void 0) return ctx;
1327
+ if (!isAbsolute(projectDirArg)) {
1328
+ throw new LocalGuidanceError(
1329
+ "invalid_request",
1330
+ `projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
1331
+ );
1332
+ }
1333
+ const dir = resolve2(projectDirArg);
1334
+ if (parse(dir).root === dir || dir === homedir()) {
1335
+ throw new LocalGuidanceError(
1336
+ "invalid_request",
1337
+ `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.`
1338
+ );
1339
+ }
1340
+ const stat = statSync(dir, { throwIfNoEntry: false });
1341
+ if (!stat?.isDirectory()) {
1342
+ throw new LocalGuidanceError(
1343
+ "invalid_request",
1344
+ `projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
1345
+ );
1346
+ }
1347
+ return { ...ctx, projectDir: dir };
1348
+ }
1327
1349
  function requireSiteFile(ctx) {
1328
1350
  const state = loadSiteFile(ctx.projectDir);
1329
1351
  if (state.kind === "corrupted") {
1330
- throw new SakupaError(
1352
+ throw new LocalGuidanceError(
1331
1353
  "invalid_request",
1332
1354
  `.sakupa/site.json in ${ctx.projectDir} is damaged: ${state.problem}. ` + siteFileRecoveryGuidance(ctx.projectDir)
1333
1355
  );
1334
1356
  }
1335
1357
  if (state.kind === "absent") {
1336
- throw new SakupaError(
1358
+ throw new LocalGuidanceError(
1337
1359
  "not_found",
1338
1360
  `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.`
1339
1361
  );
1340
1362
  }
1341
1363
  return state.file;
1342
1364
  }
1365
+ var STATIC_SUMMARY = {
1366
+ not_found: "The required local project binding or resource is unavailable; if this project has no .sakupa/site.json yet, run deploy_site first.",
1367
+ 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.",
1368
+ invalid_request: "The request arguments or local project checks did not pass.",
1369
+ validation_failed: "The request arguments or local project checks did not pass.",
1370
+ state_conflict: "The resource state has changed; re-query the current status before deciding the next step.",
1371
+ confirmation_required: "The site or billing state changed, so the previous confirmation is stale; run the preview again and confirm against the fresh snapshot.",
1372
+ payment_required: "This operation requires an active subscription; check billing_status first.",
1373
+ rate_limited: "The server rate limit was reached; retry after the returned wait time."
1374
+ };
1343
1375
  function toolError(e) {
1344
1376
  const errorCode = isSakupaError(e) ? e.code : "internal";
1345
1377
  const retryable = errorCode === "rate_limited" || errorCode === "internal";
@@ -1358,7 +1390,7 @@ function toolError(e) {
1358
1390
  )
1359
1391
  ) : void 0;
1360
1392
  const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
1361
- 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.";
1393
+ 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.");
1362
1394
  const result = structuredToolResult({
1363
1395
  schemaVersion: 1,
1364
1396
  outcome: "failed",
@@ -1377,8 +1409,54 @@ function toolError(e) {
1377
1409
  // src/tools/definitions.ts
1378
1410
  import { randomUUID } from "node:crypto";
1379
1411
  import { promises as fs2 } from "node:fs";
1380
- import { join as join3, resolve as resolve2 } from "node:path";
1381
- import { z as z2 } from "zod";
1412
+ import { join as join4, resolve as resolve3 } from "node:path";
1413
+ import { z as z3 } from "zod";
1414
+
1415
+ // src/creation-registry.ts
1416
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1417
+ import { homedir as homedir2 } from "node:os";
1418
+ import { dirname as dirname2, join as join3 } from "node:path";
1419
+ var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
1420
+ function creationRegistryPath() {
1421
+ const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
1422
+ return join3(base, ".sakupa", "created-sites.json");
1423
+ }
1424
+ function readAll() {
1425
+ const path = creationRegistryPath();
1426
+ if (!existsSync2(path)) return [];
1427
+ try {
1428
+ const parsed = JSON.parse(readFileSync2(path, "utf-8"));
1429
+ if (!Array.isArray(parsed)) return [];
1430
+ return parsed.filter(
1431
+ (e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
1432
+ );
1433
+ } catch {
1434
+ return [];
1435
+ }
1436
+ }
1437
+ function writeAll(records) {
1438
+ const path = creationRegistryPath();
1439
+ mkdirSync2(dirname2(path), { recursive: true });
1440
+ writeFileSync2(path, `${JSON.stringify(records, null, 2)}
1441
+ `, "utf-8");
1442
+ }
1443
+ function listRecentCreations(nowMs) {
1444
+ return readAll().filter((e) => {
1445
+ const t = Date.parse(e.createdAt);
1446
+ return Number.isFinite(t) && nowMs - t < RECENT_WINDOW_MS;
1447
+ });
1448
+ }
1449
+ function recordCreation(record) {
1450
+ const rest = readAll().filter((e) => e.siteId !== record.siteId);
1451
+ writeAll([...rest, record]);
1452
+ }
1453
+ function removeCreation(siteId) {
1454
+ const all = readAll();
1455
+ const rest = all.filter((e) => e.siteId !== siteId);
1456
+ if (rest.length !== all.length) writeAll(rest);
1457
+ }
1458
+
1459
+ // src/tools/definitions.ts
1382
1460
  function text(resultCode, t, data = {}, outcome = "completed") {
1383
1461
  return structuredToolResult({
1384
1462
  schemaVersion: 1,
@@ -1401,12 +1479,12 @@ ${JSON.stringify(obj, null, 2)}`;
1401
1479
  nextActions: []
1402
1480
  });
1403
1481
  }
1404
- var planEnum = z2.enum(["water", "personal", "share", "business"]);
1405
- var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
1482
+ var planEnum = z3.enum(["water", "personal", "share", "business"]);
1483
+ var severityEnum = z3.enum(["low", "medium", "high", "critical"]);
1406
1484
  function planCatalog() {
1407
- return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
1485
+ return TIER_ORDER.map((p) => `${p} JPY ${tierPriceJpy(p)}/month`).join(", ");
1408
1486
  }
1409
- var ticketCategoryEnum = z2.enum([
1487
+ var ticketCategoryEnum = z3.enum([
1410
1488
  "billing",
1411
1489
  "payment",
1412
1490
  "refund_review",
@@ -1432,22 +1510,6 @@ Analysis:`,
1432
1510
  "blocked"
1433
1511
  );
1434
1512
  }
1435
- function spaConfirmationResult(analysis) {
1436
- return text(
1437
- "spa_fallback_confirmation_required",
1438
- `SPA fallback confirmation required \u2014 nothing was deployed yet.
1439
-
1440
- 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.
1441
-
1442
- Please ask the user to choose, then re-run deploy_site with:
1443
- - spaFallback: true, spaFallbackConfirmed: true -> enable SPA fallback
1444
- - spaFallbackConfirmed: true (spaFallback omitted or false) -> deploy WITHOUT fallback (unknown paths return 404)
1445
-
1446
- Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`,
1447
- { analysis: analysisSummary(analysis), requestedConfirmation: "spa_fallback" },
1448
- "waiting_user"
1449
- );
1450
- }
1451
1513
  var MB2 = 1024 * 1024;
1452
1514
  function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
1453
1515
  const oversized = manifest.find((f) => f.size > MAX_SINGLE_FILE_BYTES);
@@ -1470,7 +1532,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
1470
1532
  async function buildHashedManifest(files, outputAbs) {
1471
1533
  const manifest = [];
1472
1534
  for (const file of files) {
1473
- const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, file.path)));
1535
+ const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, file.path)));
1474
1536
  manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
1475
1537
  }
1476
1538
  return manifest;
@@ -1489,7 +1551,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1489
1551
  `No local file matches upload target "${target.path}"; aborting upload.`
1490
1552
  );
1491
1553
  }
1492
- const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, match.path)));
1554
+ const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, match.path)));
1493
1555
  if (bytes.byteLength !== match.size) {
1494
1556
  throw new SakupaError(
1495
1557
  "validation_failed",
@@ -1500,8 +1562,23 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1500
1562
  }
1501
1563
  return targets.length;
1502
1564
  }
1503
- function registerTools(server, ctx) {
1504
- const previewHostPattern = previewHostPatternFor(ctx.apiBaseUrl);
1565
+ function freeSiteCreationBarrier() {
1566
+ const recent = listRecentCreations(Date.now());
1567
+ if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
1568
+ const registryPath = creationRegistryPath();
1569
+ return text(
1570
+ "local_site_limit_reached",
1571
+ `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.
1572
+
1573
+ ` + recent.map((r) => `- ${r.url} (project: ${r.projectDir}, created: ${r.createdAt})`).join("\n") + `
1574
+
1575
+ 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.`,
1576
+ { recentCreations: recent, limit: FREE_ACTIVE_SITES_PER_IP, registryPath },
1577
+ "blocked"
1578
+ );
1579
+ }
1580
+ function registerTools(server, baseCtx) {
1581
+ const previewHostPattern = previewHostPatternFor(baseCtx.apiBaseUrl);
1505
1582
  server.registerTool(
1506
1583
  "analyze_site",
1507
1584
  {
@@ -1509,17 +1586,15 @@ function registerTools(server, ctx) {
1509
1586
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1510
1587
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1511
1588
  inputSchema: {
1512
- outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1513
- spaFallbackRequested: z2.boolean().optional().describe("User asked for SPA fallback (unknown paths rewritten to index.html)."),
1514
- spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change.")
1589
+ projectDir: projectDirInput,
1590
+ outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection).")
1515
1591
  }
1516
1592
  },
1517
1593
  async (args) => {
1518
1594
  try {
1595
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1519
1596
  const analysis = await analyzeProject(ctx.projectDir, {
1520
- ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
1521
- ...args.spaFallbackRequested !== void 0 ? { spaFallbackRequested: args.spaFallbackRequested } : {},
1522
- ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1597
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
1523
1598
  });
1524
1599
  return textJson(
1525
1600
  "site_analysis_completed",
@@ -1539,30 +1614,28 @@ Next action: ${analysis.suggestedNextAction}`,
1539
1614
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1540
1615
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1541
1616
  inputSchema: {
1542
- outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1543
- spaFallback: z2.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
1544
- spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
1545
- publicConfirmed: z2.boolean().optional().describe(
1617
+ projectDir: projectDirInput,
1618
+ outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1619
+ spaFallback: z3.boolean().optional().describe(
1620
+ "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."
1621
+ ),
1622
+ publicConfirmed: z3.boolean().optional().describe(
1546
1623
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1547
1624
  ),
1548
- lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1625
+ lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1549
1626
  }
1550
1627
  },
1551
1628
  async (args) => {
1552
1629
  try {
1630
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1553
1631
  const analysis = await analyzeProject(ctx.projectDir, {
1554
- ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
1555
- ...args.spaFallback !== void 0 ? { spaFallbackRequested: args.spaFallback } : {},
1556
- ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1632
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
1557
1633
  });
1558
- if (analysis.spa.confirmationRequired && args.spaFallbackConfirmed !== true) {
1559
- return spaConfirmationResult(analysis);
1560
- }
1561
1634
  if (!analysis.deployable || !analysis.files) {
1562
1635
  return notDeployableResult(analysis);
1563
1636
  }
1564
1637
  const files = analysis.files;
1565
- const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1638
+ const outputAbs = resolve3(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1566
1639
  const manifest = await buildHashedManifest(files, outputAbs);
1567
1640
  const siteFileState = loadSiteFile(ctx.projectDir);
1568
1641
  if (siteFileState.kind === "corrupted") {
@@ -1576,6 +1649,10 @@ Next action: ${analysis.suggestedNextAction}`,
1576
1649
  );
1577
1650
  }
1578
1651
  const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
1652
+ if (!existing) {
1653
+ const barrier = freeSiteCreationBarrier();
1654
+ if (barrier) return barrier;
1655
+ }
1579
1656
  if (!existing && args.publicConfirmed !== true) {
1580
1657
  return text(
1581
1658
  "public_deployment_confirmation_required",
@@ -1589,8 +1666,7 @@ Next action: ${analysis.suggestedNextAction}`,
1589
1666
  const created = await ctx.client.createSite({
1590
1667
  manifest,
1591
1668
  ...args.lang !== void 0 ? { lang: args.lang } : {},
1592
- spaFallback: args.spaFallback === true,
1593
- ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1669
+ ...args.spaFallback !== void 0 ? { spaFallback: args.spaFallback } : {}
1594
1670
  });
1595
1671
  const uploaded2 = await uploadAll(ctx, created.uploadTargets, files, outputAbs);
1596
1672
  const finalized2 = await ctx.client.finalizeDeployment(
@@ -1598,17 +1674,25 @@ Next action: ${analysis.suggestedNextAction}`,
1598
1674
  created.siteId,
1599
1675
  created.credential
1600
1676
  );
1677
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1601
1678
  writeSiteFile(ctx.projectDir, {
1602
1679
  siteId: created.siteId,
1603
1680
  shortId: created.shortId,
1604
1681
  url: finalized2.url,
1605
1682
  credential: created.credential,
1606
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1683
+ createdAt,
1607
1684
  apiBaseUrl: ctx.apiBaseUrl
1608
1685
  });
1686
+ recordCreation({
1687
+ siteId: created.siteId,
1688
+ projectDir: ctx.projectDir,
1689
+ url: finalized2.url,
1690
+ createdAt
1691
+ });
1609
1692
  return text(
1610
1693
  "site_published",
1611
1694
  `Site published: ${finalized2.url}
1695
+ Project directory: ${ctx.projectDir}
1612
1696
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1613
1697
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
1614
1698
  ` : "") + `
@@ -1626,7 +1710,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1626
1710
  filesUploaded: uploaded2,
1627
1711
  totalBytes: finalized2.totalBytes,
1628
1712
  warnings: finalized2.warnings,
1629
- credentialStoredLocally: true
1713
+ credentialStoredLocally: true,
1714
+ projectDir: ctx.projectDir
1630
1715
  }
1631
1716
  );
1632
1717
  }
@@ -1634,8 +1719,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1634
1719
  const req = {
1635
1720
  manifest,
1636
1721
  ...args.lang !== void 0 ? { lang: args.lang } : {},
1637
- spaFallback: args.spaFallback === true,
1638
- ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {},
1722
+ ...args.spaFallback !== void 0 ? { spaFallback: args.spaFallback } : {},
1639
1723
  ...forceFullUpload ? { forceFullUpload: true } : {}
1640
1724
  };
1641
1725
  const deployment = await ctx.client.createDeployment(
@@ -1673,6 +1757,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1673
1757
  return text(
1674
1758
  "site_updated",
1675
1759
  `Site updated: ${finalized.url}
1760
+ Project directory: ${ctx.projectDir}
1676
1761
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1677
1762
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
1678
1763
  ` : "") + (finalized.mode === "free" ? `
@@ -1684,6 +1769,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1684
1769
  siteId: existing.siteId,
1685
1770
  url: finalized.url,
1686
1771
  mode: finalized.mode,
1772
+ projectDir: ctx.projectDir,
1687
1773
  expiresAt: finalized.expiresAt,
1688
1774
  filesUploaded: uploaded,
1689
1775
  totalBytes: finalized.totalBytes,
@@ -1701,17 +1787,18 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1701
1787
  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.",
1702
1788
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1703
1789
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1704
- inputSchema: {}
1790
+ inputSchema: { projectDir: projectDirInput }
1705
1791
  },
1706
- async () => {
1792
+ async (args) => {
1707
1793
  try {
1794
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1708
1795
  const site = requireSiteFile(ctx);
1709
1796
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
1710
1797
  return text(
1711
1798
  "site_refreshed",
1712
- `Site validity refreshed. New expiry: ${res.expiresAt}
1713
- Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`,
1714
- { siteId: site.siteId, expiresAt: res.expiresAt }
1799
+ `Site validity refreshed (project: ${ctx.projectDir}). New expiry: ${res.expiresAt}
1800
+ 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.`,
1801
+ { siteId: site.siteId, expiresAt: res.expiresAt, projectDir: ctx.projectDir }
1715
1802
  );
1716
1803
  } catch (e) {
1717
1804
  return toolError(e);
@@ -1724,13 +1811,17 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1724
1811
  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.",
1725
1812
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1726
1813
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1727
- inputSchema: {}
1814
+ inputSchema: { projectDir: projectDirInput }
1728
1815
  },
1729
- async () => {
1816
+ async (args) => {
1730
1817
  try {
1818
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1731
1819
  const site = requireSiteFile(ctx);
1732
1820
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
1733
- return textJson("site_status_returned", "Site status:", res);
1821
+ return textJson("site_status_returned", "Site status:", {
1822
+ ...res,
1823
+ projectDir: ctx.projectDir
1824
+ });
1734
1825
  } catch (e) {
1735
1826
  return toolError(e);
1736
1827
  }
@@ -1743,6 +1834,7 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1743
1834
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1744
1835
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1745
1836
  inputSchema: {
1837
+ projectDir: projectDirInput,
1746
1838
  plan: planEnum.describe(
1747
1839
  "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
1748
1840
  )
@@ -1750,6 +1842,7 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1750
1842
  },
1751
1843
  async (args) => {
1752
1844
  try {
1845
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1753
1846
  const site = requireSiteFile(ctx);
1754
1847
  const res = await ctx.client.createPlanCheckout(
1755
1848
  {
@@ -1761,7 +1854,7 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1761
1854
  );
1762
1855
  return text(
1763
1856
  "subscription_checkout_ready",
1764
- `Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
1857
+ `Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, JPY ${res.monthlyPriceJpy}/month (Japanese yen)
1765
1858
  ${res.checkoutUrl}
1766
1859
 
1767
1860
  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.
@@ -1788,13 +1881,15 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1788
1881
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1789
1882
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1790
1883
  inputSchema: {
1791
- action: z2.enum(["start", "status"]),
1792
- hostname: z2.string().optional().describe("Required for start."),
1793
- verificationId: z2.string().optional().describe("Required for status.")
1884
+ projectDir: projectDirInput,
1885
+ action: z3.enum(["start", "status"]),
1886
+ hostname: z3.string().optional().describe("Required for start."),
1887
+ verificationId: z3.string().optional().describe("Required for status.")
1794
1888
  }
1795
1889
  },
1796
1890
  async (args) => {
1797
1891
  try {
1892
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1798
1893
  const site = requireSiteFile(ctx);
1799
1894
  if (args.action === "status") {
1800
1895
  if (!args.verificationId) {
@@ -1864,16 +1959,17 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
1864
1959
  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).",
1865
1960
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1866
1961
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1867
- inputSchema: {}
1962
+ inputSchema: { projectDir: projectDirInput }
1868
1963
  },
1869
- async () => {
1964
+ async (args) => {
1870
1965
  try {
1966
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1871
1967
  const site = requireSiteFile(ctx);
1872
1968
  const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
1873
1969
  const lines = [
1874
1970
  `Billing status for site ${res.siteId} (mode: ${res.mode})`,
1875
1971
  res.permanentUrl ? `Permanent URL: ${res.permanentUrl}` : void 0,
1876
- res.plan ? `Plan: ${res.plan} (\xA5${res.monthlyPriceJpy ?? tierPriceJpy(res.plan)}/month)` : "Plan: (no subscription yet)",
1972
+ res.plan ? `Plan: ${res.plan} (JPY ${res.monthlyPriceJpy ?? tierPriceJpy(res.plan)}/month)` : "Plan: (no subscription yet)",
1877
1973
  res.subscriptionStatus ? `Subscription payment state: ${res.subscriptionStatus}` : void 0,
1878
1974
  res.cancelAtPeriodEnd ? "Renewal: CANCELED \u2014 the site reverts to free at the end of the already-paid month" : void 0,
1879
1975
  res.currentPeriodStart ? `Current paid period: ${res.currentPeriodStart} -> ${res.currentPeriodEnd ?? "?"}` : void 0,
@@ -1897,11 +1993,13 @@ Full status:`, res);
1897
1993
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1898
1994
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1899
1995
  inputSchema: {
1900
- scope: z2.enum(["site", "public_recovery"])
1996
+ projectDir: projectDirInput,
1997
+ scope: z3.enum(["site", "public_recovery"])
1901
1998
  }
1902
1999
  },
1903
2000
  async (args) => {
1904
2001
  try {
2002
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1905
2003
  if (args.scope === "site") {
1906
2004
  const site = requireSiteFile(ctx);
1907
2005
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
@@ -1952,14 +2050,16 @@ Full status:`, res);
1952
2050
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1953
2051
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1954
2052
  inputSchema: {
1955
- action: z2.enum(["start", "status", "complete"]),
1956
- hostname: z2.string().optional().describe("Required for start."),
1957
- verificationId: z2.string().optional().describe("Required for status or complete."),
1958
- preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
2053
+ projectDir: projectDirInput,
2054
+ action: z3.enum(["start", "status", "complete"]),
2055
+ hostname: z3.string().optional().describe("Required for start."),
2056
+ verificationId: z3.string().optional().describe("Required for status or complete."),
2057
+ preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
1959
2058
  }
1960
2059
  },
1961
2060
  async (args) => {
1962
2061
  try {
2062
+ const ctx = withProjectDir(baseCtx, args.projectDir);
1963
2063
  if (args.action === "start") {
1964
2064
  if (!args.hostname) {
1965
2065
  throw new SakupaError("invalid_request", "hostname is required for start");
@@ -2057,14 +2157,16 @@ ${res.archiveUrl}`,
2057
2157
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2058
2158
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2059
2159
  inputSchema: {
2160
+ projectDir: projectDirInput,
2060
2161
  category: ticketCategoryEnum,
2061
- subject: z2.string().describe("Short subject line."),
2062
- description: z2.string().describe("Problem description (no secrets, no card data)."),
2063
- contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
2162
+ subject: z3.string().describe("Short subject line."),
2163
+ description: z3.string().describe("Problem description (no secrets, no card data)."),
2164
+ contactEmail: z3.string().optional().describe("Optional contact email for follow-up.")
2064
2165
  }
2065
2166
  },
2066
2167
  async (args) => {
2067
2168
  try {
2169
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2068
2170
  const site = requireSiteFile(ctx);
2069
2171
  const res = await ctx.client.createTicket(site.credential, {
2070
2172
  siteId: site.siteId,
@@ -2090,18 +2192,20 @@ ${res.archiveUrl}`,
2090
2192
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2091
2193
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2092
2194
  inputSchema: {
2093
- toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
2094
- errorCode: z2.string().optional(),
2095
- errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
2096
- requestId: z2.string().optional(),
2097
- deploymentId: z2.string().optional(),
2195
+ projectDir: projectDirInput,
2196
+ toolName: z3.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
2197
+ errorCode: z3.string().optional(),
2198
+ errorMessage: z3.string().optional().describe("Sanitized error message (no secrets)."),
2199
+ requestId: z3.string().optional(),
2200
+ deploymentId: z3.string().optional(),
2098
2201
  severity: severityEnum.optional(),
2099
- description: z2.string().optional().describe("What happened, in the user's words (no secrets)."),
2100
- confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
2202
+ description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
2203
+ confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
2101
2204
  }
2102
2205
  },
2103
2206
  async (args) => {
2104
2207
  try {
2208
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2105
2209
  const siteState = loadSiteFile(ctx.projectDir);
2106
2210
  const site = siteState.kind === "ok" ? siteState.file : null;
2107
2211
  const diagnostics = {
@@ -2145,39 +2249,40 @@ Summary: ${res.sanitizedSummary}`,
2145
2249
  }
2146
2250
 
2147
2251
  // src/tools/lifecycle.ts
2148
- import { z as z3 } from "zod";
2149
- var deleteConfirmation = z3.object({
2150
- siteId: z3.string().min(1),
2151
- expectedSiteUpdatedAt: z3.string().datetime(),
2152
- expectedStatus: z3.enum(["active", "expired", "deleted"]),
2153
- expectedMode: z3.enum(["free", "paid"]),
2154
- expectedServingMode: z3.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
2155
- expectedShortId: z3.string().optional(),
2156
- expectedSubscriptionStatus: z3.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
2157
- expectedPlan: z3.enum(["water", "personal", "share", "business"]).optional(),
2158
- expectedCancelAtPeriodEnd: z3.boolean().optional(),
2159
- expectedCurrentPeriodEnd: z3.string().datetime().optional(),
2160
- expectedLastDeploymentId: z3.string().optional(),
2161
- expectedBoundHostnames: z3.array(z3.string()),
2162
- acknowledge: z3.literal("delete_site_and_cancel_renewal")
2252
+ import { z as z4 } from "zod";
2253
+ var deleteConfirmation = z4.object({
2254
+ siteId: z4.string().min(1),
2255
+ expectedSiteUpdatedAt: z4.string().datetime(),
2256
+ expectedStatus: z4.enum(["active", "expired", "deleted"]),
2257
+ expectedMode: z4.enum(["free", "paid"]),
2258
+ expectedServingMode: z4.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
2259
+ expectedShortId: z4.string().optional(),
2260
+ expectedSubscriptionStatus: z4.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
2261
+ expectedPlan: z4.enum(["water", "personal", "share", "business"]).optional(),
2262
+ expectedCancelAtPeriodEnd: z4.boolean().optional(),
2263
+ expectedCurrentPeriodEnd: z4.string().datetime().optional(),
2264
+ expectedLastDeploymentId: z4.string().optional(),
2265
+ expectedBoundHostnames: z4.array(z4.string()),
2266
+ acknowledge: z4.literal("delete_site_and_cancel_renewal")
2163
2267
  });
2164
- var unbindConfirmation = z3.object({
2165
- siteId: z3.string().min(1),
2166
- bindingId: z3.string().min(1),
2167
- expectedBindingUpdatedAt: z3.string().datetime(),
2168
- expectedBindingStatus: z3.enum(["provisioning", "active"]),
2169
- apexDomain: z3.string().min(1),
2170
- expectedBoundHostnames: z3.array(z3.string()),
2171
- acknowledge: z3.literal("unbind_domain_and_remove_custom_hostnames")
2268
+ var unbindConfirmation = z4.object({
2269
+ siteId: z4.string().min(1),
2270
+ bindingId: z4.string().min(1),
2271
+ expectedBindingUpdatedAt: z4.string().datetime(),
2272
+ expectedBindingStatus: z4.enum(["provisioning", "active"]),
2273
+ apexDomain: z4.string().min(1),
2274
+ expectedBoundHostnames: z4.array(z4.string()),
2275
+ acknowledge: z4.literal("unbind_domain_and_remove_custom_hostnames")
2172
2276
  });
2173
- function registerLifecycleTools(server, ctx) {
2277
+ function registerLifecycleTools(server, baseCtx) {
2174
2278
  server.registerTool(
2175
2279
  "delete_site",
2176
2280
  {
2177
2281
  description: "Preview or execute deletion of this Sakupa site. Execution requires an exact server-validated confirmation bound to the current site state.",
2178
2282
  inputSchema: {
2179
- action: z3.enum(["preview", "confirm"]),
2180
- operationId: z3.string().min(1).optional(),
2283
+ projectDir: projectDirInput,
2284
+ action: z4.enum(["preview", "confirm"]),
2285
+ operationId: z4.string().min(1).optional(),
2181
2286
  confirmation: deleteConfirmation.optional()
2182
2287
  },
2183
2288
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -2185,6 +2290,7 @@ function registerLifecycleTools(server, ctx) {
2185
2290
  },
2186
2291
  async (args) => {
2187
2292
  try {
2293
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2188
2294
  const site = requireSiteFile(ctx);
2189
2295
  if (!args.operationId) {
2190
2296
  throw new Error("operationId is required for delete_site");
@@ -2211,13 +2317,14 @@ function registerLifecycleTools(server, ctx) {
2211
2317
  confirmation: args.confirmation
2212
2318
  });
2213
2319
  deleteSiteFile(ctx.projectDir);
2320
+ removeCreation(site.siteId);
2214
2321
  return structuredToolResult({
2215
2322
  schemaVersion: 1,
2216
2323
  outcome: result.servingDeletionPending ? "pending_provider" : "completed",
2217
2324
  resultCode: "site_deleted",
2218
2325
  operationId: args.operationId,
2219
- summary: "Site deleted; the local management credential file was removed.",
2220
- data: { result },
2326
+ summary: `Site deleted; the local management credential file was removed from ${ctx.projectDir}.`,
2327
+ data: { result, projectDir: ctx.projectDir },
2221
2328
  nextActions: []
2222
2329
  });
2223
2330
  } catch (error) {
@@ -2230,8 +2337,9 @@ function registerLifecycleTools(server, ctx) {
2230
2337
  {
2231
2338
  description: "Preview or execute removal of the custom apex/www serving surface while preserving the subscription and permanent Sakupa URL.",
2232
2339
  inputSchema: {
2233
- action: z3.enum(["preview", "confirm"]),
2234
- operationId: z3.string().min(1).optional(),
2340
+ projectDir: projectDirInput,
2341
+ action: z4.enum(["preview", "confirm"]),
2342
+ operationId: z4.string().min(1).optional(),
2235
2343
  confirmation: unbindConfirmation.optional()
2236
2344
  },
2237
2345
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -2239,6 +2347,7 @@ function registerLifecycleTools(server, ctx) {
2239
2347
  },
2240
2348
  async (args) => {
2241
2349
  try {
2350
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2242
2351
  const site = requireSiteFile(ctx);
2243
2352
  if (!args.operationId) throw new Error("operationId is required for unbind_domain");
2244
2353
  if (args.action === "preview") {
@@ -2269,7 +2378,7 @@ function registerLifecycleTools(server, ctx) {
2269
2378
  outcome: result.servingDeletionPending ? "pending_provider" : "completed",
2270
2379
  resultCode: "domain_unbound",
2271
2380
  operationId: args.operationId,
2272
- summary: "Custom domain unbound; the subscription, deployed content, and permanent Sakupa URL are unchanged.",
2381
+ summary: `Custom domain unbound; the subscription, deployed content, and permanent Sakupa URL are unchanged. (project: ${ctx.projectDir})`,
2273
2382
  data: { result },
2274
2383
  nextActions: [{ tool: "site_status", allowed: true }]
2275
2384
  });
@@ -2284,19 +2393,20 @@ function registerLifecycleTools(server, ctx) {
2284
2393
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2285
2394
 
2286
2395
  // src/tools/billing.ts
2287
- import { z as z4 } from "zod";
2288
- var plan = z4.enum(["water", "personal", "share", "business"]);
2289
- function registerBillingTools(server, ctx) {
2396
+ import { z as z5 } from "zod";
2397
+ var plan = z5.enum(["water", "personal", "share", "business"]);
2398
+ function registerBillingTools(server, baseCtx) {
2290
2399
  server.registerTool(
2291
2400
  "list_billing_plans",
2292
2401
  {
2293
2402
  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.",
2294
- inputSchema: {},
2403
+ inputSchema: { projectDir: projectDirInput },
2295
2404
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2296
2405
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
2297
2406
  },
2298
- async () => {
2407
+ async (args) => {
2299
2408
  try {
2409
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2300
2410
  const catalog = await ctx.client.getBillingPlanCatalog();
2301
2411
  return structuredToolResult({
2302
2412
  schemaVersion: 1,
@@ -2316,14 +2426,16 @@ function registerBillingTools(server, ctx) {
2316
2426
  {
2317
2427
  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.",
2318
2428
  inputSchema: {
2429
+ projectDir: projectDirInput,
2319
2430
  targetPlan: plan,
2320
- operationId: z4.string().min(1)
2431
+ operationId: z5.string().min(1)
2321
2432
  },
2322
2433
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2323
2434
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
2324
2435
  },
2325
2436
  async (args) => {
2326
2437
  try {
2438
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2327
2439
  const site = requireSiteFile(ctx);
2328
2440
  const result = await ctx.client.changeSubscriptionPlan(site.credential, {
2329
2441
  siteId: site.siteId,
@@ -2382,11 +2494,28 @@ Workflow:
2382
2494
  5. create_support_ticket (subscribed sites) opens a support ticket; report_bug sends a
2383
2495
  sanitized diagnostic report after the user explicitly confirms it.
2384
2496
 
2497
+ Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
2498
+ binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2499
+ the directory the user is currently working in, on every call. Without it the server falls
2500
+ back to its startup directory, which may be a different project than the one the user is
2501
+ looking at. analyze_site, deploy_site, site_status, refresh_site, delete_site and unbind_domain echo
2502
+ the directory they acted on \u2014 verify it matches the user's active project.
2503
+
2385
2504
  Safety boundaries:
2386
2505
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
2387
2506
  - Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
2388
2507
  - Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
2389
2508
  - A subscription never grants domain ownership; only DNS verification does.
2509
+ - Never repeat, echo, or memorize the credential value from .sakupa/site.json \u2014 quoting it
2510
+ into the conversation copies the site's only key outside the protected local file. Read it
2511
+ only through the tools.
2512
+ - Before deploying, if the entry HTML lacks a lang attribute, add one matching the content
2513
+ language (infer it from the content) and then deploy; only skip when the user explicitly
2514
+ wants no lang attribute.
2515
+ - Prices are authoritative in JPY (Japanese yen). When talking with a user in a language
2516
+ other than Japanese, look up the approximate exchange rate and show an estimated local
2517
+ price next to the JPY amount, clearly marked as an estimate \u2014 Stripe always settles the
2518
+ real charge in JPY. Never show a bare Yen sign.
2390
2519
  - The management credential lives only in .sakupa/site.json; never share or upload it. Without
2391
2520
  a bound custom domain, a lost credential is unrecoverable by design. manage_billing then opens
2392
2521
  Stripe's public no-code portal login, where the customer verifies the checkout email with a