@sakupa/mcp 0.5.0 → 0.6.0

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 +474 -174
  2. package/dist/index.js +476 -174
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -121,7 +121,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
121
121
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
122
122
 
123
123
  // ../core/dist/domain/version.js
124
- var SAKUPA_MCP_VERSION = "0.5.0";
124
+ var SAKUPA_MCP_VERSION = "0.6.0";
125
125
 
126
126
  // ../core/dist/domain/errors.js
127
127
  var HTTP_STATUS = {
@@ -446,8 +446,17 @@ async function sha256Hex(bytes) {
446
446
  return hex;
447
447
  }
448
448
 
449
- // ../core/dist/domain/subscription.js
450
- var SUBSCRIPTION_WARNING_TEXT = "You are subscribing this site ({siteUrl}) to Sakupa Hosting: the {plan} monthly plan (\xA5{priceJpy}/month).\n\nPaying makes THIS site permanent on its {siteUrl} address \u2014 it stops expiring. Binding a custom domain afterwards is an optional included extra: it requires proving DNS control of that domain, and whether or not you ever bind one does not change the subscription or qualify for a refund.\n\nIf this site outgrows its plan, Sakupa automatically upgrades the subscription to the next plan (water -> personal -> share -> business) and renewals bill the new plan. There is no metered overage: above the Business plan, growth is limited instead of billed further.\n\nYou can cancel anytime; when the subscription ends, the site immediately becomes a free temporary site again (24h validity) and all paid data \u2014 custom domain bindings included \u2014 is removed.";
449
+ // ../core/dist/domain/billing-operation.js
450
+ var BILLING_MUTATION_LIMITS = {
451
+ globalPerTenMinutes: 20,
452
+ sitePerTenMinutes: 1,
453
+ perBillingPeriod: 3,
454
+ maxConcurrency: 2,
455
+ leaseSeconds: 30,
456
+ maxConsecutiveFailures: 5,
457
+ circuitBreakSeconds: 15 * 60,
458
+ providerTimeoutMs: 1e4
459
+ };
451
460
 
452
461
  // ../core/dist/dto.js
453
462
  var CREDENTIAL_HEADER = "x-sakupa-credential";
@@ -458,12 +467,12 @@ var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
458
467
  var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
459
468
  var utf8Encoder = new TextEncoder();
460
469
 
470
+ // ../core/dist/services/authorization.js
471
+ var AUTHORIZATION_TTL_MS = 15 * 60 * 1e3;
472
+
461
473
  // ../core/dist/services/subscriptions.js
462
474
  var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
463
475
 
464
- // ../core/dist/services/billing-cancellation.js
465
- var encoder = new TextEncoder();
466
-
467
476
  // src/version.ts
468
477
  var MCP_VERSION = SAKUPA_MCP_VERSION;
469
478
  var CLIENT_TYPE = "sakupa-mcp";
@@ -629,6 +638,12 @@ var HttpApiClient = class {
629
638
  async recoverDomain(req) {
630
639
  return this.call("POST", "/v1/domains/recover", { body: req });
631
640
  }
641
+ async getRecoveryStatus(verificationId) {
642
+ return this.call(
643
+ "GET",
644
+ `/v1/domains/recover/${encodeURIComponent(verificationId)}`
645
+ );
646
+ }
632
647
  async completeRecovery(verificationId, req) {
633
648
  return this.call(
634
649
  "POST",
@@ -659,19 +674,18 @@ var HttpApiClient = class {
659
674
  { credential }
660
675
  );
661
676
  }
662
- async setBillingPlan(siteId, credential, req) {
677
+ async getBillingPlanCatalog() {
678
+ return this.call("GET", "/v1/billing/plans");
679
+ }
680
+ async manageSubscription(siteId, credential, req) {
663
681
  return this.call(
664
682
  "POST",
665
- `/v1/sites/${encodeURIComponent(siteId)}/billing/plan`,
683
+ `/v1/sites/${encodeURIComponent(siteId)}/billing/manage`,
666
684
  { credential, body: req }
667
685
  );
668
686
  }
669
- async requestBillingCancellation(req) {
670
- return this.call(
671
- "POST",
672
- "/v1/billing/cancellation-requests",
673
- { body: req }
674
- );
687
+ async getPublicBillingPortal() {
688
+ return this.call("GET", "/v1/billing/portal");
675
689
  }
676
690
  async createTicket(credential, req) {
677
691
  return this.call("POST", "/v1/support/tickets", {
@@ -1178,6 +1192,47 @@ async function analyzeProject(projectDir, opts = {}) {
1178
1192
  };
1179
1193
  }
1180
1194
 
1195
+ // src/tools/result.ts
1196
+ import { z } from "zod";
1197
+ var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
1198
+ schemaVersion: z.literal(1),
1199
+ outcome: z.enum([
1200
+ "completed",
1201
+ "preview",
1202
+ "waiting_user",
1203
+ "pending_provider",
1204
+ "blocked",
1205
+ "expired",
1206
+ "failed"
1207
+ ]),
1208
+ resultCode: z.string(),
1209
+ operationId: z.string().optional(),
1210
+ summary: z.string(),
1211
+ data: z.record(z.string(), z.unknown()),
1212
+ userAction: z.object({
1213
+ type: z.enum(["open_url", "confirm_in_mcp", "configure_dns"]),
1214
+ provider: z.enum(["stripe", "sakupa"]).optional(),
1215
+ url: z.string().optional(),
1216
+ expiresAt: z.string().optional(),
1217
+ expectedOutcome: z.string(),
1218
+ resumeWith: z.object({ tool: z.string(), arguments: z.record(z.string(), z.unknown()) }).optional()
1219
+ }).optional(),
1220
+ nextActions: z.array(
1221
+ z.object({
1222
+ tool: z.string(),
1223
+ arguments: z.record(z.string(), z.unknown()).optional(),
1224
+ allowed: z.boolean(),
1225
+ reasonCode: z.string().optional()
1226
+ })
1227
+ )
1228
+ };
1229
+ function structuredToolResult(envelope) {
1230
+ return {
1231
+ content: [{ type: "text", text: envelope.summary }],
1232
+ structuredContent: envelope
1233
+ };
1234
+ }
1235
+
1181
1236
  // src/tools/context.ts
1182
1237
  function requireSiteFile(ctx) {
1183
1238
  const file = readSiteFile(ctx.projectDir);
@@ -1190,39 +1245,53 @@ function requireSiteFile(ctx) {
1190
1245
  return file;
1191
1246
  }
1192
1247
  function toolError(e) {
1193
- if (isSakupaError(e)) {
1194
- let text2 = `Error [${e.code}]: ${e.message}`;
1195
- if (e.details !== void 0) {
1196
- text2 += `
1197
- Details: ${JSON.stringify(e.details, null, 2)}`;
1198
- }
1199
- return { content: [{ type: "text", text: text2 }], isError: true };
1200
- }
1201
- const message = e instanceof Error ? e.message : String(e);
1202
- return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
1248
+ const errorCode = isSakupaError(e) ? e.code : "internal";
1249
+ const retryable = false;
1250
+ const safeSummary = errorCode === "not_found" ? "\u6240\u9700\u7684\u672C\u5730\u9879\u76EE\u7ED1\u5B9A\u6216\u8D44\u6E90\u4E0D\u53EF\u7528\uFF1B\u5982\u679C\u672C\u5730\u6CA1\u6709 .sakupa/site.json\uFF0C\u8BF7\u5148\u8FD0\u884C deploy_site first\u3002" : errorCode === "unauthorized" ? "\u5F53\u524D\u64CD\u4F5C\u672A\u901A\u8FC7\u7AD9\u70B9\u6743\u9650\u6821\u9A8C\u3002" : errorCode === "invalid_request" || errorCode === "validation_failed" ? "\u8BF7\u6C42\u53C2\u6570\u6216\u672C\u5730\u9879\u76EE\u68C0\u67E5\u672A\u901A\u8FC7\u3002" : errorCode === "state_conflict" ? "\u8D44\u6E90\u72B6\u6001\u5DF2\u7ECF\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u67E5\u8BE2\u72B6\u6001\u540E\u518D\u51B3\u5B9A\u4E0B\u4E00\u6B65\u3002" : retryable ? "\u5916\u90E8\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\u6216\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" : "\u64CD\u4F5C\u5931\u8D25\uFF1B\u672A\u8FD4\u56DE\u670D\u52A1\u7AEF\u5185\u90E8\u8BE6\u60C5\u3002";
1251
+ const result = structuredToolResult({
1252
+ schemaVersion: 1,
1253
+ outcome: "failed",
1254
+ resultCode: `error_${errorCode}`,
1255
+ summary: safeSummary,
1256
+ data: { errorCode, retryable },
1257
+ nextActions: []
1258
+ });
1259
+ return { ...result, isError: true };
1203
1260
  }
1204
1261
 
1205
1262
  // src/tools/definitions.ts
1206
1263
  import { randomUUID } from "node:crypto";
1207
1264
  import { promises as fs2 } from "node:fs";
1208
1265
  import { join as join3, resolve as resolve2 } from "node:path";
1209
- import { z } from "zod";
1210
- function text(t) {
1211
- return { content: [{ type: "text", text: t }] };
1266
+ import { z as z2 } from "zod";
1267
+ function text(resultCode, t, data = {}, outcome = "completed") {
1268
+ return structuredToolResult({
1269
+ schemaVersion: 1,
1270
+ outcome,
1271
+ resultCode,
1272
+ summary: t,
1273
+ data,
1274
+ nextActions: []
1275
+ });
1212
1276
  }
1213
- function textJson(header, obj) {
1214
- return text(`${header}
1215
- ${JSON.stringify(obj, null, 2)}`);
1277
+ function textJson(resultCode, header, obj, outcome = "completed") {
1278
+ const summary = `${header}
1279
+ ${JSON.stringify(obj, null, 2)}`;
1280
+ return structuredToolResult({
1281
+ schemaVersion: 1,
1282
+ outcome,
1283
+ resultCode,
1284
+ summary,
1285
+ data: typeof obj === "object" && obj !== null ? { result: obj } : { result: obj },
1286
+ nextActions: []
1287
+ });
1216
1288
  }
1217
- var planEnum = z.enum(["water", "personal", "share", "business"]);
1218
- var severityEnum = z.enum(["low", "medium", "high", "critical"]);
1289
+ var planEnum = z2.enum(["water", "personal", "share", "business"]);
1290
+ var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
1219
1291
  function planCatalog() {
1220
1292
  return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
1221
1293
  }
1222
- function subscriptionWarning(siteUrl, plan) {
1223
- return SUBSCRIPTION_WARNING_TEXT.replaceAll("{siteUrl}", siteUrl).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
1224
- }
1225
- var ticketCategoryEnum = z.enum([
1294
+ var ticketCategoryEnum = z2.enum([
1226
1295
  "billing",
1227
1296
  "payment",
1228
1297
  "refund_review",
@@ -1240,14 +1309,17 @@ function analysisSummary(analysis) {
1240
1309
  }
1241
1310
  function notDeployableResult(analysis) {
1242
1311
  return textJson(
1312
+ "site_analysis_not_deployable",
1243
1313
  `This project is NOT deployable as-is. No files were uploaded and no API call was made.
1244
1314
  Next action: ${analysis.suggestedNextAction}
1245
1315
  Analysis:`,
1246
- analysisSummary(analysis)
1316
+ analysisSummary(analysis),
1317
+ "blocked"
1247
1318
  );
1248
1319
  }
1249
1320
  function spaConfirmationResult(analysis) {
1250
1321
  return text(
1322
+ "spa_fallback_confirmation_required",
1251
1323
  `SPA fallback confirmation required \u2014 nothing was deployed yet.
1252
1324
 
1253
1325
  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.
@@ -1256,7 +1328,9 @@ Please ask the user to choose, then re-run deploy_site with:
1256
1328
  - spaFallback: true, spaFallbackConfirmed: true -> enable SPA fallback
1257
1329
  - spaFallbackConfirmed: true (spaFallback omitted or false) -> deploy WITHOUT fallback (unknown paths return 404)
1258
1330
 
1259
- Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`
1331
+ Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`,
1332
+ { analysis: analysisSummary(analysis), requestedConfirmation: "spa_fallback" },
1333
+ "waiting_user"
1260
1334
  );
1261
1335
  }
1262
1336
  var MB2 = 1024 * 1024;
@@ -1316,10 +1390,12 @@ function registerTools(server, ctx) {
1316
1390
  "analyze_site",
1317
1391
  {
1318
1392
  description: "Analyze the local project and decide whether it can be deployed as a static site. Detects the framework, the built static output directory (dist/build/out/...), missing index.html, SSR/API-route/database-runtime risks, SPA fallback needs, forbidden files (secrets, .env, archives, media) and size limits. Sakupa deploys ONLY prebuilt static output \u2014 never source, secrets or server code. Run this before deploy_site.",
1393
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1394
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1319
1395
  inputSchema: {
1320
- outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1321
- spaFallbackRequested: z.boolean().optional().describe("User asked for SPA fallback (unknown paths rewritten to index.html)."),
1322
- spaFallbackConfirmed: z.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change.")
1396
+ outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1397
+ spaFallbackRequested: z2.boolean().optional().describe("User asked for SPA fallback (unknown paths rewritten to index.html)."),
1398
+ spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change.")
1323
1399
  }
1324
1400
  },
1325
1401
  async (args) => {
@@ -1330,6 +1406,7 @@ function registerTools(server, ctx) {
1330
1406
  ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1331
1407
  });
1332
1408
  return textJson(
1409
+ "site_analysis_completed",
1333
1410
  `Analysis of ${ctx.projectDir}
1334
1411
  Next action: ${analysis.suggestedNextAction}`,
1335
1412
  analysisSummary(analysis)
@@ -1343,14 +1420,16 @@ Next action: ${analysis.suggestedNextAction}`,
1343
1420
  "deploy_site",
1344
1421
  {
1345
1422
  description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://{shortId}.sakupa.com) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscribed sites are permanent). Runs analyze_site first and refuses to upload source projects, secrets, .env files, archives, media or server code. Never uploads anything when the analysis says the project is not deployable.`,
1423
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1424
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1346
1425
  inputSchema: {
1347
- outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1348
- spaFallback: z.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
1349
- spaFallbackConfirmed: z.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
1350
- publicConfirmed: z.boolean().optional().describe(
1426
+ outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1427
+ spaFallback: z2.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
1428
+ spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
1429
+ publicConfirmed: z2.boolean().optional().describe(
1351
1430
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1352
1431
  ),
1353
- lang: z.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1432
+ lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1354
1433
  }
1355
1434
  },
1356
1435
  async (args) => {
@@ -1372,7 +1451,10 @@ Next action: ${analysis.suggestedNextAction}`,
1372
1451
  const existing = readSiteFile(ctx.projectDir);
1373
1452
  if (!existing && args.publicConfirmed !== true) {
1374
1453
  return text(
1375
- `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`
1454
+ "public_deployment_confirmation_required",
1455
+ `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`,
1456
+ { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
1457
+ "waiting_user"
1376
1458
  );
1377
1459
  }
1378
1460
  ensureUploadSizeWithinLimits(manifest, !existing);
@@ -1398,6 +1480,7 @@ Next action: ${analysis.suggestedNextAction}`,
1398
1480
  apiBaseUrl: ctx.apiBaseUrl
1399
1481
  });
1400
1482
  return text(
1483
+ "site_published",
1401
1484
  `Site published: ${finalized2.url}
1402
1485
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1403
1486
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
@@ -1405,7 +1488,19 @@ Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1405
1488
  This is a FREE temporary preview: it stays live for ${FREE_SITE_TTL_HOURS} hours. Deploying again or calling refresh_site extends the validity; subscribing the site (subscribe_site) makes this URL permanent. The management credential was saved to .sakupa/site.json \u2014 keep that file: it is the only way to manage this site.
1406
1489
  ` + credentialGitReminder(ctx.projectDir) + (finalized2.warnings.length > 0 ? `
1407
1490
  Warnings:
1408
- ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
1491
+ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1492
+ {
1493
+ siteId: created.siteId,
1494
+ shortId: created.shortId,
1495
+ url: finalized2.url,
1496
+ deploymentId: created.deploymentId,
1497
+ mode: finalized2.mode,
1498
+ expiresAt: finalized2.expiresAt,
1499
+ filesUploaded: uploaded2,
1500
+ totalBytes: finalized2.totalBytes,
1501
+ warnings: finalized2.warnings,
1502
+ credentialStoredLocally: true
1503
+ }
1409
1504
  );
1410
1505
  }
1411
1506
  const updateOnce = async (forceFullUpload) => {
@@ -1439,6 +1534,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
1439
1534
  const { uploaded, finalized } = update;
1440
1535
  writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
1441
1536
  return text(
1537
+ "site_updated",
1442
1538
  `Site updated: ${finalized.url}
1443
1539
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1444
1540
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
@@ -1446,7 +1542,16 @@ Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1446
1542
  Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh_site call. Subscribing (subscribe_site) makes the site permanent.
1447
1543
  ` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
1448
1544
  Warnings:
1449
- ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1545
+ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1546
+ {
1547
+ siteId: existing.siteId,
1548
+ url: finalized.url,
1549
+ mode: finalized.mode,
1550
+ expiresAt: finalized.expiresAt,
1551
+ filesUploaded: uploaded,
1552
+ totalBytes: finalized.totalBytes,
1553
+ warnings: finalized.warnings
1554
+ }
1450
1555
  );
1451
1556
  } catch (e) {
1452
1557
  return toolError(e);
@@ -1457,6 +1562,8 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1457
1562
  "refresh_site",
1458
1563
  {
1459
1564
  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.",
1565
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1566
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1460
1567
  inputSchema: {}
1461
1568
  },
1462
1569
  async () => {
@@ -1464,8 +1571,10 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1464
1571
  const site = requireSiteFile(ctx);
1465
1572
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
1466
1573
  return text(
1574
+ "site_refreshed",
1467
1575
  `Site validity refreshed. New expiry: ${res.expiresAt}
1468
- Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`
1576
+ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`,
1577
+ { siteId: site.siteId, expiresAt: res.expiresAt }
1469
1578
  );
1470
1579
  } catch (e) {
1471
1580
  return toolError(e);
@@ -1476,13 +1585,15 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1476
1585
  "site_status",
1477
1586
  {
1478
1587
  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.",
1588
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1589
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1479
1590
  inputSchema: {}
1480
1591
  },
1481
1592
  async () => {
1482
1593
  try {
1483
1594
  const site = requireSiteFile(ctx);
1484
1595
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
1485
- return textJson("Site status:", res);
1596
+ return textJson("site_status_returned", "Site status:", res);
1486
1597
  } catch (e) {
1487
1598
  return toolError(e);
1488
1599
  }
@@ -1491,40 +1602,42 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1491
1602
  server.registerTool(
1492
1603
  "subscribe_site",
1493
1604
  {
1494
- description: `Create a Stripe Checkout link that subscribes THIS site to a Sakupa Hosting monthly plan (${planCatalog()}). Paying makes the site PERMANENT on its {shortId}.sakupa.com URL \u2014 no more 24h expiry; that is the core value of paying. Binding a custom domain afterwards (bind_domain) is an optional included extra and requires DNS control of that domain. Owner-only: requires this project's site credential (.sakupa/site.json) \u2014 deploy_site first. If the site outgrows its plan, Sakupa auto-upgrades to the next plan (capped at Business, where growth is limited instead of billed further). Card details are entered only on the Stripe-hosted page \u2014 never through the AI tool. Without confirm: true this tool only shows the mandatory disclosure and makes no API call.`,
1605
+ description: `Create a Stripe Checkout link that subscribes THIS site to a Sakupa Hosting monthly plan (${planCatalog()}). Paying makes the site PERMANENT on its {shortId}.sakupa.com URL \u2014 no more 24h expiry; that is the core value of paying. Binding a custom domain afterwards (bind_domain) is an optional included extra and requires DNS control of that domain. Owner-only: requires this project's site credential (.sakupa/site.json) \u2014 deploy_site first. If the site outgrows its plan, Sakupa shows an over-limit notice and never changes billing automatically. The owner can explicitly choose another plan through Stripe Customer Portal. Card details are entered only on the Stripe-hosted page \u2014 never through the AI tool. Opening and completing Stripe Checkout is the final subscription confirmation.`,
1606
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1607
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1495
1608
  inputSchema: {
1496
1609
  plan: planEnum.describe(
1497
1610
  "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
1498
- ),
1499
- confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
1611
+ )
1500
1612
  }
1501
1613
  },
1502
1614
  async (args) => {
1503
1615
  try {
1504
1616
  const site = requireSiteFile(ctx);
1505
- const siteUrl = site.url ?? (site.shortId ? `https://${site.shortId}.sakupa.com` : site.siteId);
1506
- if (args.confirm !== true) {
1507
- return text(
1508
- `${subscriptionWarning(siteUrl, args.plan)}
1509
-
1510
- No checkout link was created yet. Please show this disclosure to the user and, after their explicit confirmation, re-run subscribe_site with confirm: true.`
1511
- );
1512
- }
1513
1617
  const res = await ctx.client.createPlanCheckout(
1514
1618
  {
1515
1619
  siteId: site.siteId,
1516
1620
  plan: args.plan,
1517
- idempotencyKey: randomUUID(),
1518
- confirmPlan: true
1621
+ idempotencyKey: randomUUID()
1519
1622
  },
1520
1623
  site.credential
1521
1624
  );
1522
1625
  return text(
1626
+ "subscription_checkout_ready",
1523
1627
  `Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
1524
1628
  ${res.checkoutUrl}
1525
1629
 
1526
1630
  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.
1527
- Once payment confirms, the site becomes permanent on its current URL. Binding a custom domain (bind_domain) is optional and still requires DNS verification.`
1631
+ Once payment confirms, the site becomes permanent on its current URL. Binding a custom domain (bind_domain) is optional and still requires DNS verification.`,
1632
+ {
1633
+ siteId: res.siteId,
1634
+ plan: res.plan,
1635
+ monthlyPriceJpy: res.monthlyPriceJpy,
1636
+ checkoutUrl: res.checkoutUrl,
1637
+ sessionId: res.sessionId,
1638
+ finalConfirmationProvider: "stripe"
1639
+ },
1640
+ "waiting_user"
1528
1641
  );
1529
1642
  } catch (e) {
1530
1643
  return toolError(e);
@@ -1535,36 +1648,54 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1535
1648
  "bind_domain",
1536
1649
  {
1537
1650
  description: "Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent {shortId}.sakupa.com URL keeps working alongside it. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership. A binding request never reserves the domain: whoever proves DNS control first gets it, and unverified requests expire after 72 hours. Call again with verificationId to check progress.",
1651
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1652
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1538
1653
  inputSchema: {
1539
- hostname: z.string().describe(
1540
- 'Domain to bind: the apex ("example.com") or its www form ("www.example.com") \u2014 both mean the same unit. Other subdomains cannot be bound.'
1541
- ),
1542
- verificationId: z.string().optional().describe("Check an existing DNS verification instead of starting a new one.")
1654
+ action: z2.enum(["start", "status"]),
1655
+ hostname: z2.string().optional().describe("Required for start."),
1656
+ verificationId: z2.string().optional().describe("Required for status.")
1543
1657
  }
1544
1658
  },
1545
1659
  async (args) => {
1546
1660
  try {
1547
1661
  const site = requireSiteFile(ctx);
1548
- if (args.verificationId !== void 0) {
1662
+ if (args.action === "status") {
1663
+ if (!args.verificationId) {
1664
+ throw new SakupaError("invalid_request", "verificationId is required for status");
1665
+ }
1549
1666
  const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
1550
1667
  if (res2.status === "verified") {
1551
1668
  writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
1552
1669
  }
1553
1670
  return text(
1671
+ res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
1554
1672
  `DNS verification ${res2.verificationId}: ${res2.status}
1555
1673
  ${res2.message}
1556
1674
  ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
1557
1675
  ` : "") + (res2.pendingDnsRecords.length > 0 ? `
1558
1676
  DNS records still required:
1559
- ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
1677
+ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
1678
+ {
1679
+ verificationId: res2.verificationId,
1680
+ status: res2.status,
1681
+ apexDomain: res2.apexDomain,
1682
+ provisioningJobId: res2.provisioningJobId,
1683
+ pendingDnsRecords: res2.pendingDnsRecords,
1684
+ message: res2.message
1685
+ },
1686
+ res2.status === "verified" ? "completed" : "pending_provider"
1560
1687
  );
1561
1688
  }
1689
+ if (!args.hostname) {
1690
+ throw new SakupaError("invalid_request", "hostname is required for start");
1691
+ }
1562
1692
  const req = {
1563
1693
  siteId: site.siteId,
1564
1694
  hostname: args.hostname
1565
1695
  };
1566
1696
  const res = await ctx.client.bindDomain(site.credential, req);
1567
1697
  return text(
1698
+ "domain_verification_started",
1568
1699
  `Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
1569
1700
 
1570
1701
  1. Prove control of ${res.apexDomain} by creating this DNS record:
@@ -1575,7 +1706,15 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
1575
1706
 
1576
1707
  2. Serving DNS (after verification): ${res.servingInstructions}
1577
1708
 
1578
- Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`
1709
+ Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`,
1710
+ {
1711
+ verificationId: res.verificationId,
1712
+ apexDomain: res.apexDomain,
1713
+ includedHostnames: res.includedHostnames,
1714
+ verificationRecord: res.verificationRecord,
1715
+ servingInstructions: res.servingInstructions
1716
+ },
1717
+ "waiting_user"
1579
1718
  );
1580
1719
  } catch (e) {
1581
1720
  return toolError(e);
@@ -1586,6 +1725,8 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
1586
1725
  "billing_status",
1587
1726
  {
1588
1727
  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).",
1728
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1729
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1589
1730
  inputSchema: {}
1590
1731
  },
1591
1732
  async () => {
@@ -1604,7 +1745,7 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
1604
1745
  res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
1605
1746
  res.risks.pastDue ? "ATTENTION: renewal payment failing \u2014 update the payment method (manage_billing). Serving continues while Stripe retries; if Stripe gives up, the site reverts to free." : void 0
1606
1747
  ].filter((l) => l !== void 0);
1607
- return textJson(`${lines.join("\n")}
1748
+ return textJson("billing_status_returned", `${lines.join("\n")}
1608
1749
 
1609
1750
  Full status:`, res);
1610
1751
  } catch (e) {
@@ -1615,41 +1756,53 @@ Full status:`, res);
1615
1756
  server.registerTool(
1616
1757
  "manage_billing",
1617
1758
  {
1618
- description: "Open the Stripe-hosted billing portal for this site: update the payment method, view invoices, or cancel the subscription. All billing operations happen on the Stripe-hosted page \u2014 never inside the AI tool. With .sakupa/site.json, this opens the site-specific portal. If the local credential was lost, pass the remembered Sakupa site URL: Sakupa returns the same generic response for every URL and, only when an eligible subscription exists, emails its Stripe billing address a one-time period-end cancellation confirmation link. This never restores site authority.",
1759
+ description: "Open the Stripe-hosted billing portal for this site: update the payment method, view invoices, or cancel the subscription. All billing operations happen on the Stripe-hosted page \u2014 never inside the AI tool. With .sakupa/site.json, this opens the site-specific portal. Without the local credential, this returns Stripe's public no-code Customer Portal login page. The customer enters the checkout email and confirms a one-time passcode sent by Stripe. This never restores Sakupa site authority.",
1760
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1761
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1619
1762
  inputSchema: {
1620
- siteUrl: z.string().optional().describe(
1621
- "Without .sakupa/site.json only: the remembered https://{shortId}.sakupa.com URL."
1622
- )
1763
+ scope: z2.enum(["site", "public_recovery"])
1623
1764
  }
1624
1765
  },
1625
1766
  async (args) => {
1626
1767
  try {
1627
- const site = readSiteFile(ctx.projectDir);
1628
- if (site) {
1629
- if (args.siteUrl !== void 0) {
1630
- return text(
1631
- "A local Sakupa credential is present, so manage_billing uses the site-specific Stripe portal. Remove siteUrl and call manage_billing again; the public lost-key path is deliberately unavailable while site authority is present."
1632
- );
1633
- }
1768
+ if (args.scope === "site") {
1769
+ const site = requireSiteFile(ctx);
1634
1770
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
1635
- return text(
1636
- `Stripe billing portal for this site:
1637
- ${res2.portalUrl}
1638
-
1639
- Open this link in a browser to update the payment method, view invoices, or manage the subscription. The link is temporary \u2014 create a fresh one when needed.`
1640
- );
1641
- }
1642
- if (args.siteUrl === void 0) {
1643
- return text(
1644
- "No .sakupa/site.json was found. Provide the remembered Sakupa site URL to request a cancellation email. Sakupa will not reveal whether the URL, site, billing email, or subscription exists. If eligible, the original Stripe billing email receives a short-lived one-time link. Opening it only shows the consequences; the user must press the confirmation button to stop the next renewal. This does not recover a key or grant deployment, download, or content access."
1645
- );
1771
+ return structuredToolResult({
1772
+ schemaVersion: 1,
1773
+ outcome: "waiting_user",
1774
+ resultCode: "site_billing_portal_ready",
1775
+ summary: `\u5DF2\u521B\u5EFA\u6B64\u7AD9\u70B9\u7684 Stripe \u5BA2\u6237\u95E8\u6237\u77ED\u65F6\u94FE\u63A5\uFF1A${res2.portalUrl}\u3002\u4EFB\u4F55\u53D8\u66F4\u4ECD\u987B\u5728 Stripe \u9875\u9762\u5B8C\u6210\u3002`,
1776
+ data: { scope: args.scope, portalUrl: res2.portalUrl },
1777
+ userAction: {
1778
+ type: "open_url",
1779
+ provider: "stripe",
1780
+ url: res2.portalUrl,
1781
+ expectedOutcome: "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u7BA1\u7406\u4ED8\u6B3E\u65B9\u5F0F\u3001\u53D1\u7968\u6216\u53D6\u6D88\u7EED\u8BA2"
1782
+ },
1783
+ nextActions: [{ tool: "billing_status", allowed: true }]
1784
+ });
1646
1785
  }
1647
- const res = await ctx.client.requestBillingCancellation({ siteUrl: args.siteUrl });
1648
- return text(
1649
- `${res.message}
1650
-
1651
- For privacy, this response is identical whether or not the URL, site, customer, email, or subscription exists. Check the original Stripe billing inbox. The email link expires quickly and can only schedule cancellation at the current paid period end; it cannot restore the Sakupa credential or access the site.`
1652
- );
1786
+ const res = await ctx.client.getPublicBillingPortal();
1787
+ return structuredToolResult({
1788
+ schemaVersion: 1,
1789
+ outcome: "waiting_user",
1790
+ resultCode: "public_billing_recovery_portal_ready",
1791
+ summary: `Stripe \u516C\u5171\u90AE\u7BB1 OTP \u767B\u5F55\u9875\uFF1A${res.portalUrl}\u3002\u5B83\u4F7F\u7528 one-time passcode\uFF0Cdoes not recover the Sakupa key\uFF0C\u4E5F\u4E0D\u6388\u4E88\u7AD9\u70B9\u6743\u9650\uFF1B\u540C\u90AE\u7BB1\u5B58\u5728\u591A\u4E2A Customer \u65F6\uFF0CStripe \u53EF\u80FD\u53EA\u6253\u5F00 most recently created \u7684\u53EF\u7528\u8BB0\u5F55\u3002`,
1792
+ data: {
1793
+ scope: args.scope,
1794
+ portalUrl: res.portalUrl,
1795
+ grantsSiteAuthority: false,
1796
+ acceptsSiteIdentifier: false
1797
+ },
1798
+ userAction: {
1799
+ type: "open_url",
1800
+ provider: "stripe",
1801
+ url: res.portalUrl,
1802
+ expectedOutcome: "\u7528\u6237\u7531 Stripe \u9A8C\u8BC1\u8D26\u5355\u90AE\u7BB1\u540E\u67E5\u770B\u5E76\u53D6\u6D88\u95E8\u6237\u4E2D\u663E\u793A\u7684\u8BA2\u9605"
1803
+ },
1804
+ nextActions: []
1805
+ });
1653
1806
  } catch (e) {
1654
1807
  return toolError(e);
1655
1808
  }
@@ -1659,17 +1812,24 @@ For privacy, this response is identical whether or not the URL, site, customer,
1659
1812
  "recover_domain_site",
1660
1813
  {
1661
1814
  description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Call first with the hostname to get the DNS record, then again with verificationId to complete recovery (writes a new .sakupa/site.json and returns a download link for the current site content).",
1815
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1816
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1662
1817
  inputSchema: {
1663
- hostname: z.string().describe('Hostname of the site to recover, e.g. "www.example.com".'),
1664
- verificationId: z.string().optional().describe("Complete a recovery previously started for this hostname."),
1665
- preserveExistingCredentials: z.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
1818
+ action: z2.enum(["start", "status", "complete"]),
1819
+ hostname: z2.string().optional().describe("Required for start."),
1820
+ verificationId: z2.string().optional().describe("Required for status or complete."),
1821
+ preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
1666
1822
  }
1667
1823
  },
1668
1824
  async (args) => {
1669
1825
  try {
1670
- if (args.verificationId === void 0) {
1826
+ if (args.action === "start") {
1827
+ if (!args.hostname) {
1828
+ throw new SakupaError("invalid_request", "hostname is required for start");
1829
+ }
1671
1830
  const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
1672
1831
  return text(
1832
+ "domain_recovery_started",
1673
1833
  `Recovery started for ${args.hostname} (apex domain: ${res2.apexDomain}).
1674
1834
 
1675
1835
  Create this DNS record to prove apex-domain control:
@@ -1681,9 +1841,40 @@ ${res2.message}
1681
1841
 
1682
1842
  IMPORTANT: completing recovery revokes ALL previous local authorizations for this site by default (this protects you if the old project or its credential leaked). If you want to keep the old credentials working, pass preserveExistingCredentials: true when completing.
1683
1843
 
1684
- After the DNS record resolves, re-run recover_domain_site with verificationId: "${res2.verificationId}".`
1844
+ After the DNS record resolves, re-run recover_domain_site with verificationId: "${res2.verificationId}".`,
1845
+ {
1846
+ verificationId: res2.verificationId,
1847
+ apexDomain: res2.apexDomain,
1848
+ verificationRecord: res2.verificationRecord,
1849
+ revokesPreviousCredentialsByDefault: true
1850
+ },
1851
+ "waiting_user"
1852
+ );
1853
+ }
1854
+ if (!args.verificationId) {
1855
+ throw new SakupaError(
1856
+ "invalid_request",
1857
+ "verificationId is required for status or complete"
1685
1858
  );
1686
1859
  }
1860
+ if (args.action === "status") {
1861
+ const res2 = await ctx.client.getRecoveryStatus(args.verificationId);
1862
+ return structuredToolResult({
1863
+ schemaVersion: 1,
1864
+ outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
1865
+ resultCode: res2.status === "expired" ? "domain_recovery_expired" : res2.readyToComplete ? "domain_recovery_ready" : "domain_recovery_pending_dns",
1866
+ summary: `DNS \u6062\u590D\u9A8C\u8BC1\u72B6\u6001\uFF1A${res2.status}`,
1867
+ data: { recovery: res2 },
1868
+ nextActions: [
1869
+ {
1870
+ tool: "recover_domain_site",
1871
+ arguments: { action: "complete", verificationId: args.verificationId },
1872
+ allowed: res2.readyToComplete,
1873
+ ...res2.readyToComplete ? {} : { reasonCode: res2.status }
1874
+ }
1875
+ ]
1876
+ });
1877
+ }
1687
1878
  const res = await ctx.client.completeRecovery(args.verificationId, {
1688
1879
  ...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
1689
1880
  });
@@ -1695,6 +1886,7 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
1695
1886
  apiBaseUrl: ctx.apiBaseUrl
1696
1887
  });
1697
1888
  return text(
1889
+ "domain_recovery_completed",
1698
1890
  `Recovery complete.
1699
1891
  Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
1700
1892
  Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
@@ -1702,54 +1894,16 @@ Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (pr
1702
1894
  A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
1703
1895
  ` + credentialGitReminder(ctx.projectDir) + `
1704
1896
  Download the current site content (signed URL):
1705
- ${res.archiveUrl}`
1706
- );
1707
- } catch (e) {
1708
- return toolError(e);
1709
- }
1710
- }
1711
- );
1712
- server.registerTool(
1713
- "set_billing_plan",
1714
- {
1715
- description: `Change this site's hosting plan or cancel/re-enable renewal. Plans: ${planCatalog()}. Plan changes go through the Stripe subscription; renewals bill the new plan. Auto-upgrade (one plan up when usage exceeds the current plan, capped at Business) is built in and not configurable. cancelRenewal: true ends the subscription at the period end; the paid service remains available until then. The site reverts to a free 24h site only after the signed Stripe final-cancellation webhook arrives. The API returns the consequences first; explicit owner confirmation (confirm: true) is required before anything is applied.`,
1716
- inputSchema: {
1717
- plan: planEnum.optional().describe("Target monthly plan."),
1718
- cancelRenewal: z.boolean().optional().describe(
1719
- "true: cancel renewal (the site stays permanent to the end of the paid month, then reverts to free). false: re-enable renewal."
1720
- ),
1721
- confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
1722
- }
1723
- },
1724
- async (args) => {
1725
- try {
1726
- const site = requireSiteFile(ctx);
1727
- const req = {
1728
- ...args.plan !== void 0 ? { plan: args.plan } : {},
1729
- ...args.cancelRenewal !== void 0 ? { cancelRenewal: args.cancelRenewal } : {},
1730
- confirm: args.confirm === true
1731
- };
1732
- try {
1733
- const res = await ctx.client.setBillingPlan(site.siteId, site.credential, req);
1734
- return textJson(
1735
- `Billing updated for site ${res.siteId} (mode: ${res.mode}).
1736
- Consequences:
1737
- ${res.consequences.map((c) => `- ${c}`).join("\n")}
1738
- Result:`,
1739
- res
1740
- );
1741
- } catch (e) {
1742
- if (isSakupaError(e) && e.code === "confirmation_required") {
1743
- return text(
1744
- `Confirmation required before changing the billing plan \u2014 nothing was applied.
1745
-
1746
- ${e.message}
1747
- ` + (e.details !== void 0 ? `${JSON.stringify(e.details, null, 2)}
1748
- ` : "") + "\nPlease show these consequences to the user and, after their explicit confirmation, re-run set_billing_plan with the same arguments plus confirm: true."
1749
- );
1897
+ ${res.archiveUrl}`,
1898
+ {
1899
+ siteId: res.siteId,
1900
+ boundHostnames: res.boundHostnames,
1901
+ revokedPreviousCredentials: res.revokedPreviousCredentials,
1902
+ archiveUrl: res.archiveUrl,
1903
+ archiveExpiresAt: res.archiveExpiresAt,
1904
+ credentialStoredLocally: true
1750
1905
  }
1751
- throw e;
1752
- }
1906
+ );
1753
1907
  } catch (e) {
1754
1908
  return toolError(e);
1755
1909
  }
@@ -1759,11 +1913,13 @@ ${e.message}
1759
1913
  "create_support_ticket",
1760
1914
  {
1761
1915
  description: "Create a Sakupa support ticket for billing, payment, refund review, domain verification, deployment, serving or other issues the MCP cannot solve automatically. Do not include secrets, credentials or card data in the description.",
1916
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1917
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1762
1918
  inputSchema: {
1763
1919
  category: ticketCategoryEnum,
1764
- subject: z.string().describe("Short subject line."),
1765
- description: z.string().describe("Problem description (no secrets, no card data)."),
1766
- contactEmail: z.string().optional().describe("Optional contact email for follow-up.")
1920
+ subject: z2.string().describe("Short subject line."),
1921
+ description: z2.string().describe("Problem description (no secrets, no card data)."),
1922
+ contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
1767
1923
  }
1768
1924
  },
1769
1925
  async (args) => {
@@ -1776,7 +1932,11 @@ ${e.message}
1776
1932
  description: args.description,
1777
1933
  ...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
1778
1934
  });
1779
- return text(`Support ticket created: ${res.ticketId} (status: ${res.status}).`);
1935
+ return text(
1936
+ "support_ticket_created",
1937
+ `Support ticket created: ${res.ticketId} (status: ${res.status}).`,
1938
+ { ticketId: res.ticketId, status: res.status }
1939
+ );
1780
1940
  } catch (e) {
1781
1941
  return toolError(e);
1782
1942
  }
@@ -1786,15 +1946,17 @@ ${e.message}
1786
1946
  "report_bug",
1787
1947
  {
1788
1948
  description: "Prepare and submit a sanitized bug report when a Sakupa tool failed and the issue looks like a product bug. Only whitelisted structured diagnostics are sent (tool name, error code/message, site id, bound domain, deployment id, timestamps, client/MCP version, request id) \u2014 NEVER file contents, source code, secrets, .env values or credentials. Without confirmSubmit: true the exact payload is shown for user review and nothing is submitted.",
1949
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1950
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1789
1951
  inputSchema: {
1790
- toolName: z.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
1791
- errorCode: z.string().optional(),
1792
- errorMessage: z.string().optional().describe("Sanitized error message (no secrets)."),
1793
- requestId: z.string().optional(),
1794
- deploymentId: z.string().optional(),
1952
+ toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
1953
+ errorCode: z2.string().optional(),
1954
+ errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
1955
+ requestId: z2.string().optional(),
1956
+ deploymentId: z2.string().optional(),
1795
1957
  severity: severityEnum.optional(),
1796
- description: z.string().optional().describe("What happened, in the user's words (no secrets)."),
1797
- confirmSubmit: z.boolean().optional().describe("User reviewed the report payload and approved submission.")
1958
+ description: z2.string().optional().describe("What happened, in the user's words (no secrets)."),
1959
+ confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
1798
1960
  }
1799
1961
  },
1800
1962
  async (args) => {
@@ -1820,14 +1982,18 @@ ${e.message}
1820
1982
  };
1821
1983
  if (args.confirmSubmit !== true) {
1822
1984
  return textJson(
1985
+ "bug_report_preview_ready",
1823
1986
  "Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Please show it to the user; re-run report_bug with confirmSubmit: true to submit.",
1824
- payload
1987
+ payload,
1988
+ "preview"
1825
1989
  );
1826
1990
  }
1827
1991
  const res = await ctx.client.reportBug(payload, site?.credential);
1828
1992
  return text(
1993
+ "bug_report_submitted",
1829
1994
  `Bug report submitted. Ticket: ${res.ticketId}
1830
- Summary: ${res.sanitizedSummary}`
1995
+ Summary: ${res.sanitizedSummary}`,
1996
+ { ticketId: res.ticketId, sanitizedSummary: res.sanitizedSummary }
1831
1997
  );
1832
1998
  } catch (e) {
1833
1999
  return toolError(e);
@@ -1838,6 +2004,134 @@ Summary: ${res.sanitizedSummary}`
1838
2004
 
1839
2005
  // src/server.ts
1840
2006
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2007
+
2008
+ // src/tools/billing.ts
2009
+ import { z as z3 } from "zod";
2010
+ var plan = z3.enum(["water", "personal", "share", "business"]);
2011
+ var trigger = z3.enum(["actual_overage", "credential_automation"]);
2012
+ function registerBillingTools(server, ctx) {
2013
+ server.registerTool(
2014
+ "list_billing_plans",
2015
+ {
2016
+ 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.",
2017
+ inputSchema: {},
2018
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2019
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
2020
+ },
2021
+ async () => {
2022
+ try {
2023
+ const catalog = await ctx.client.getBillingPlanCatalog();
2024
+ return structuredToolResult({
2025
+ schemaVersion: 1,
2026
+ outcome: "completed",
2027
+ resultCode: "billing_catalog_returned",
2028
+ summary: `\u5DF2\u8FD4\u56DE ${catalog.plans.length} \u4E2A\u6708\u4ED8\u65B9\u6848\uFF1BStripe \u6258\u7BA1\u9875\u9762\u662F\u4ED8\u8D39\u4E0E\u6539\u6863\u7684\u6700\u7EC8\u786E\u8BA4\u5165\u53E3\u3002`,
2029
+ data: { catalog },
2030
+ nextActions: [{ tool: "subscribe_site", allowed: true }]
2031
+ });
2032
+ } catch (error) {
2033
+ return toolError(error);
2034
+ }
2035
+ }
2036
+ );
2037
+ server.registerTool(
2038
+ "manage_subscription",
2039
+ {
2040
+ description: "Manage this site subscription through explicit actions. open_plan_change returns a Stripe-hosted confirmation URL. create_automation_authorization returns a short-lived Sakupa authorization URL binding the exact cap and triggers. disable_automation is immediate and owner-authorized. request_authorized_upgrade executes only under an existing exact grant, fresh usage snapshot, catalog version and server-side safety gates.",
2041
+ inputSchema: {
2042
+ action: z3.enum([
2043
+ "open_plan_change",
2044
+ "create_automation_authorization",
2045
+ "disable_automation",
2046
+ "request_authorized_upgrade"
2047
+ ]),
2048
+ operationId: z3.string().min(1).describe("Stable idempotency key chosen by the caller."),
2049
+ targetPlan: plan.optional(),
2050
+ maxPlan: plan.optional(),
2051
+ allowedTriggers: z3.array(trigger).min(1).optional(),
2052
+ observedSnapshotId: z3.string().optional(),
2053
+ observedCatalogVersion: z3.string().optional()
2054
+ },
2055
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2056
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
2057
+ },
2058
+ async (args) => {
2059
+ try {
2060
+ const site = requireSiteFile(ctx);
2061
+ let request;
2062
+ if (args.action === "open_plan_change") {
2063
+ if (!args.targetPlan) {
2064
+ throw new SakupaError("invalid_request", "targetPlan is required for open_plan_change");
2065
+ }
2066
+ request = {
2067
+ action: args.action,
2068
+ targetPlan: args.targetPlan,
2069
+ operationId: args.operationId
2070
+ };
2071
+ } else if (args.action === "create_automation_authorization") {
2072
+ if (!args.maxPlan || !args.allowedTriggers) {
2073
+ throw new SakupaError(
2074
+ "invalid_request",
2075
+ "maxPlan and allowedTriggers are required for authorization"
2076
+ );
2077
+ }
2078
+ request = {
2079
+ action: args.action,
2080
+ maxPlan: args.maxPlan,
2081
+ allowedTriggers: args.allowedTriggers,
2082
+ operationId: args.operationId
2083
+ };
2084
+ } else if (args.action === "disable_automation") {
2085
+ request = { action: args.action, operationId: args.operationId };
2086
+ } else {
2087
+ if (!args.targetPlan) {
2088
+ throw new SakupaError(
2089
+ "invalid_request",
2090
+ "targetPlan is required for request_authorized_upgrade"
2091
+ );
2092
+ }
2093
+ request = {
2094
+ action: args.action,
2095
+ operationId: args.operationId,
2096
+ targetPlan: args.targetPlan,
2097
+ trigger: "credential_automation",
2098
+ ...args.observedSnapshotId ? { observedSnapshotId: args.observedSnapshotId } : {},
2099
+ ...args.observedCatalogVersion ? { observedCatalogVersion: args.observedCatalogVersion } : {}
2100
+ };
2101
+ }
2102
+ const result = await ctx.client.manageSubscription(site.siteId, site.credential, request);
2103
+ const portalUrl = "portalUrl" in result ? result.portalUrl : void 0;
2104
+ const authorizationUrl = "authorizationUrl" in result ? result.authorizationUrl : void 0;
2105
+ const executionOutcome = "outcome" in result ? result.outcome : void 0;
2106
+ const outcome = portalUrl || authorizationUrl ? "waiting_user" : executionOutcome === "pending_provider" ? "pending_provider" : executionOutcome === "blocked" ? "blocked" : "completed";
2107
+ const resultCode = "resultCode" in result ? result.resultCode : portalUrl ? "stripe_plan_change_confirmation_required" : authorizationUrl ? "automation_authorization_required" : args.action === "disable_automation" ? "automation_disabled" : "subscription_action_completed";
2108
+ const url = portalUrl ?? authorizationUrl;
2109
+ return structuredToolResult({
2110
+ schemaVersion: 1,
2111
+ outcome,
2112
+ resultCode,
2113
+ operationId: args.operationId,
2114
+ summary: url ? "\u5DF2\u521B\u5EFA\u77ED\u65F6\u6548\u6258\u7BA1\u786E\u8BA4\u94FE\u63A5\uFF1B\u5F53\u524D\u8BA2\u9605\u5C1A\u672A\u56E0\u521B\u5EFA\u94FE\u63A5\u800C\u6539\u53D8\u3002" : `\u8BA2\u9605\u52A8\u4F5C\u7ED3\u679C\uFF1A${resultCode}`,
2115
+ data: { action: args.action, result },
2116
+ ...url ? {
2117
+ userAction: {
2118
+ type: "open_url",
2119
+ provider: portalUrl ? "stripe" : "sakupa",
2120
+ url,
2121
+ ..."expiresAt" in result && typeof result.expiresAt === "string" ? { expiresAt: result.expiresAt } : {},
2122
+ expectedOutcome: portalUrl ? "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u786E\u8BA4\u540E\uFF0C\u7531 webhook \u66F4\u65B0 Sakupa \u72B6\u6001" : "\u7528\u6237\u5728 Sakupa \u77ED\u65F6\u6548\u9875\u9762\u786E\u8BA4\u7CBE\u786E\u81EA\u52A8\u5347\u7EA7\u6388\u6743"
2123
+ }
2124
+ } : {},
2125
+ nextActions: [{ tool: "billing_status", allowed: true }]
2126
+ });
2127
+ } catch (error) {
2128
+ return toolError(error);
2129
+ }
2130
+ }
2131
+ );
2132
+ }
2133
+
2134
+ // src/server.ts
1841
2135
  var INSTRUCTIONS = `Sakupa publishes AI-made static websites. AI-made pages, live in seconds.
1842
2136
 
1843
2137
  Workflow:
@@ -1849,14 +2143,17 @@ Workflow:
1849
2143
  management credential in .sakupa/site.json. Deploying again updates the site and refreshes
1850
2144
  its validity; refresh_site extends validity without uploading.
1851
2145
  3. To make the site PERMANENT, subscribe it to a monthly hosting plan (subscribe_site ->
1852
- Stripe-hosted checkout; water/personal/share/business, auto-upgrade when the site outgrows
1853
- its plan). Paying makes the {shortId}.sakupa.com URL permanent \u2014 that is what payment buys.
2146
+ Stripe-hosted checkout; water/personal/share/business). Paying makes the
2147
+ {shortId}.sakupa.com URL permanent \u2014 that is what payment buys. Usage over the chosen plan
2148
+ shows an over-limit notice by default. Automatic upgrades require a separate, bounded,
2149
+ one-time Sakupa authorization and can never exceed the user-approved plan cap.
1854
2150
  4. Optionally bind a custom domain to the subscribed site (bind_domain): an included extra
1855
2151
  serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
1856
2152
  first verified request wins; unverified requests expire after 72 hours. billing_status,
1857
- set_billing_plan, manage_billing and recover_domain_site manage the paid lifecycle.
1858
- An immediate Stripe cancellation reverts the site to a free 24h site and removes all paid
1859
- data when Sakupa receives the signed cancellation webhook.
2153
+ manage_subscription, manage_billing and recover_domain_site manage the paid lifecycle. Manual plan
2154
+ changes are confirmed only on Stripe Customer Portal and synchronized by Stripe webhook.
2155
+ A cancellation keeps the site paid through the current period. Sakupa reverts it to a free
2156
+ 24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
1860
2157
 
1861
2158
  Safety boundaries:
1862
2159
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
@@ -1864,9 +2161,9 @@ Safety boundaries:
1864
2161
  - Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
1865
2162
  - A subscription never grants domain ownership; only DNS verification does.
1866
2163
  - The management credential lives only in .sakupa/site.json; never share or upload it. Without
1867
- a bound custom domain, a lost credential is unrecoverable by design. manage_billing can accept
1868
- the remembered Sakupa URL and request a one-time cancellation link sent only to the exact
1869
- subscription's Stripe billing email; it never restores site authority.`;
2164
+ a bound custom domain, a lost credential is unrecoverable by design. manage_billing then opens
2165
+ Stripe's public no-code portal login, where the customer verifies the checkout email with a
2166
+ Stripe one-time passcode; it never restores site authority.`;
1870
2167
  function createSakupaMcpServer(opts) {
1871
2168
  const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
1872
2169
  const server = new McpServer(
@@ -1878,6 +2175,11 @@ function createSakupaMcpServer(opts) {
1878
2175
  projectDir: opts.projectDir,
1879
2176
  apiBaseUrl: opts.apiBaseUrl
1880
2177
  });
2178
+ registerBillingTools(server, {
2179
+ client,
2180
+ projectDir: opts.projectDir,
2181
+ apiBaseUrl: opts.apiBaseUrl
2182
+ });
1881
2183
  return server;
1882
2184
  }
1883
2185
  export {