@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/bin.js CHANGED
@@ -127,7 +127,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
127
127
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
128
128
 
129
129
  // ../core/dist/domain/version.js
130
- var SAKUPA_MCP_VERSION = "0.5.0";
130
+ var SAKUPA_MCP_VERSION = "0.6.0";
131
131
 
132
132
  // ../core/dist/domain/errors.js
133
133
  var HTTP_STATUS = {
@@ -452,8 +452,17 @@ async function sha256Hex(bytes) {
452
452
  return hex;
453
453
  }
454
454
 
455
- // ../core/dist/domain/subscription.js
456
- 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.";
455
+ // ../core/dist/domain/billing-operation.js
456
+ var BILLING_MUTATION_LIMITS = {
457
+ globalPerTenMinutes: 20,
458
+ sitePerTenMinutes: 1,
459
+ perBillingPeriod: 3,
460
+ maxConcurrency: 2,
461
+ leaseSeconds: 30,
462
+ maxConsecutiveFailures: 5,
463
+ circuitBreakSeconds: 15 * 60,
464
+ providerTimeoutMs: 1e4
465
+ };
457
466
 
458
467
  // ../core/dist/dto.js
459
468
  var CREDENTIAL_HEADER = "x-sakupa-credential";
@@ -464,12 +473,12 @@ var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
464
473
  var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
465
474
  var utf8Encoder = new TextEncoder();
466
475
 
476
+ // ../core/dist/services/authorization.js
477
+ var AUTHORIZATION_TTL_MS = 15 * 60 * 1e3;
478
+
467
479
  // ../core/dist/services/subscriptions.js
468
480
  var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
469
481
 
470
- // ../core/dist/services/billing-cancellation.js
471
- var encoder = new TextEncoder();
472
-
473
482
  // src/server.ts
474
483
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
475
484
 
@@ -581,6 +590,12 @@ var HttpApiClient = class {
581
590
  async recoverDomain(req) {
582
591
  return this.call("POST", "/v1/domains/recover", { body: req });
583
592
  }
593
+ async getRecoveryStatus(verificationId) {
594
+ return this.call(
595
+ "GET",
596
+ `/v1/domains/recover/${encodeURIComponent(verificationId)}`
597
+ );
598
+ }
584
599
  async completeRecovery(verificationId, req) {
585
600
  return this.call(
586
601
  "POST",
@@ -611,19 +626,18 @@ var HttpApiClient = class {
611
626
  { credential }
612
627
  );
613
628
  }
614
- async setBillingPlan(siteId, credential, req) {
629
+ async getBillingPlanCatalog() {
630
+ return this.call("GET", "/v1/billing/plans");
631
+ }
632
+ async manageSubscription(siteId, credential, req) {
615
633
  return this.call(
616
634
  "POST",
617
- `/v1/sites/${encodeURIComponent(siteId)}/billing/plan`,
635
+ `/v1/sites/${encodeURIComponent(siteId)}/billing/manage`,
618
636
  { credential, body: req }
619
637
  );
620
638
  }
621
- async requestBillingCancellation(req) {
622
- return this.call(
623
- "POST",
624
- "/v1/billing/cancellation-requests",
625
- { body: req }
626
- );
639
+ async getPublicBillingPortal() {
640
+ return this.call("GET", "/v1/billing/portal");
627
641
  }
628
642
  async createTicket(credential, req) {
629
643
  return this.call("POST", "/v1/support/tickets", {
@@ -643,7 +657,7 @@ var HttpApiClient = class {
643
657
  import { randomUUID } from "node:crypto";
644
658
  import { promises as fs2 } from "node:fs";
645
659
  import { join as join3, resolve as resolve2 } from "node:path";
646
- import { z } from "zod";
660
+ import { z as z2 } from "zod";
647
661
 
648
662
  // src/analyze/analyzer.ts
649
663
  import { promises as fs } from "node:fs";
@@ -1134,6 +1148,47 @@ function credentialGitReminder(projectDir) {
1134
1148
  var MCP_VERSION = SAKUPA_MCP_VERSION;
1135
1149
  var CLIENT_TYPE = "sakupa-mcp";
1136
1150
 
1151
+ // src/tools/result.ts
1152
+ import { z } from "zod";
1153
+ var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
1154
+ schemaVersion: z.literal(1),
1155
+ outcome: z.enum([
1156
+ "completed",
1157
+ "preview",
1158
+ "waiting_user",
1159
+ "pending_provider",
1160
+ "blocked",
1161
+ "expired",
1162
+ "failed"
1163
+ ]),
1164
+ resultCode: z.string(),
1165
+ operationId: z.string().optional(),
1166
+ summary: z.string(),
1167
+ data: z.record(z.string(), z.unknown()),
1168
+ userAction: z.object({
1169
+ type: z.enum(["open_url", "confirm_in_mcp", "configure_dns"]),
1170
+ provider: z.enum(["stripe", "sakupa"]).optional(),
1171
+ url: z.string().optional(),
1172
+ expiresAt: z.string().optional(),
1173
+ expectedOutcome: z.string(),
1174
+ resumeWith: z.object({ tool: z.string(), arguments: z.record(z.string(), z.unknown()) }).optional()
1175
+ }).optional(),
1176
+ nextActions: z.array(
1177
+ z.object({
1178
+ tool: z.string(),
1179
+ arguments: z.record(z.string(), z.unknown()).optional(),
1180
+ allowed: z.boolean(),
1181
+ reasonCode: z.string().optional()
1182
+ })
1183
+ )
1184
+ };
1185
+ function structuredToolResult(envelope) {
1186
+ return {
1187
+ content: [{ type: "text", text: envelope.summary }],
1188
+ structuredContent: envelope
1189
+ };
1190
+ }
1191
+
1137
1192
  // src/tools/context.ts
1138
1193
  function requireSiteFile(ctx) {
1139
1194
  const file = readSiteFile(ctx.projectDir);
@@ -1146,35 +1201,49 @@ function requireSiteFile(ctx) {
1146
1201
  return file;
1147
1202
  }
1148
1203
  function toolError(e) {
1149
- if (isSakupaError(e)) {
1150
- let text2 = `Error [${e.code}]: ${e.message}`;
1151
- if (e.details !== void 0) {
1152
- text2 += `
1153
- Details: ${JSON.stringify(e.details, null, 2)}`;
1154
- }
1155
- return { content: [{ type: "text", text: text2 }], isError: true };
1156
- }
1157
- const message = e instanceof Error ? e.message : String(e);
1158
- return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
1204
+ const errorCode = isSakupaError(e) ? e.code : "internal";
1205
+ const retryable = false;
1206
+ 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";
1207
+ const result = structuredToolResult({
1208
+ schemaVersion: 1,
1209
+ outcome: "failed",
1210
+ resultCode: `error_${errorCode}`,
1211
+ summary: safeSummary,
1212
+ data: { errorCode, retryable },
1213
+ nextActions: []
1214
+ });
1215
+ return { ...result, isError: true };
1159
1216
  }
1160
1217
 
1161
1218
  // src/tools/definitions.ts
1162
- function text(t) {
1163
- return { content: [{ type: "text", text: t }] };
1219
+ function text(resultCode, t, data = {}, outcome = "completed") {
1220
+ return structuredToolResult({
1221
+ schemaVersion: 1,
1222
+ outcome,
1223
+ resultCode,
1224
+ summary: t,
1225
+ data,
1226
+ nextActions: []
1227
+ });
1164
1228
  }
1165
- function textJson(header, obj) {
1166
- return text(`${header}
1167
- ${JSON.stringify(obj, null, 2)}`);
1229
+ function textJson(resultCode, header, obj, outcome = "completed") {
1230
+ const summary = `${header}
1231
+ ${JSON.stringify(obj, null, 2)}`;
1232
+ return structuredToolResult({
1233
+ schemaVersion: 1,
1234
+ outcome,
1235
+ resultCode,
1236
+ summary,
1237
+ data: typeof obj === "object" && obj !== null ? { result: obj } : { result: obj },
1238
+ nextActions: []
1239
+ });
1168
1240
  }
1169
- var planEnum = z.enum(["water", "personal", "share", "business"]);
1170
- var severityEnum = z.enum(["low", "medium", "high", "critical"]);
1241
+ var planEnum = z2.enum(["water", "personal", "share", "business"]);
1242
+ var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
1171
1243
  function planCatalog() {
1172
1244
  return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
1173
1245
  }
1174
- function subscriptionWarning(siteUrl, plan) {
1175
- return SUBSCRIPTION_WARNING_TEXT.replaceAll("{siteUrl}", siteUrl).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
1176
- }
1177
- var ticketCategoryEnum = z.enum([
1246
+ var ticketCategoryEnum = z2.enum([
1178
1247
  "billing",
1179
1248
  "payment",
1180
1249
  "refund_review",
@@ -1192,14 +1261,17 @@ function analysisSummary(analysis) {
1192
1261
  }
1193
1262
  function notDeployableResult(analysis) {
1194
1263
  return textJson(
1264
+ "site_analysis_not_deployable",
1195
1265
  `This project is NOT deployable as-is. No files were uploaded and no API call was made.
1196
1266
  Next action: ${analysis.suggestedNextAction}
1197
1267
  Analysis:`,
1198
- analysisSummary(analysis)
1268
+ analysisSummary(analysis),
1269
+ "blocked"
1199
1270
  );
1200
1271
  }
1201
1272
  function spaConfirmationResult(analysis) {
1202
1273
  return text(
1274
+ "spa_fallback_confirmation_required",
1203
1275
  `SPA fallback confirmation required \u2014 nothing was deployed yet.
1204
1276
 
1205
1277
  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.
@@ -1208,7 +1280,9 @@ Please ask the user to choose, then re-run deploy_site with:
1208
1280
  - spaFallback: true, spaFallbackConfirmed: true -> enable SPA fallback
1209
1281
  - spaFallbackConfirmed: true (spaFallback omitted or false) -> deploy WITHOUT fallback (unknown paths return 404)
1210
1282
 
1211
- Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`
1283
+ Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`,
1284
+ { analysis: analysisSummary(analysis), requestedConfirmation: "spa_fallback" },
1285
+ "waiting_user"
1212
1286
  );
1213
1287
  }
1214
1288
  var MB2 = 1024 * 1024;
@@ -1268,10 +1342,12 @@ function registerTools(server, ctx) {
1268
1342
  "analyze_site",
1269
1343
  {
1270
1344
  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.",
1345
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1346
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1271
1347
  inputSchema: {
1272
- outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1273
- spaFallbackRequested: z.boolean().optional().describe("User asked for SPA fallback (unknown paths rewritten to index.html)."),
1274
- spaFallbackConfirmed: z.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change.")
1348
+ outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1349
+ spaFallbackRequested: z2.boolean().optional().describe("User asked for SPA fallback (unknown paths rewritten to index.html)."),
1350
+ spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change.")
1275
1351
  }
1276
1352
  },
1277
1353
  async (args) => {
@@ -1282,6 +1358,7 @@ function registerTools(server, ctx) {
1282
1358
  ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1283
1359
  });
1284
1360
  return textJson(
1361
+ "site_analysis_completed",
1285
1362
  `Analysis of ${ctx.projectDir}
1286
1363
  Next action: ${analysis.suggestedNextAction}`,
1287
1364
  analysisSummary(analysis)
@@ -1295,14 +1372,16 @@ Next action: ${analysis.suggestedNextAction}`,
1295
1372
  "deploy_site",
1296
1373
  {
1297
1374
  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.`,
1375
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1376
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1298
1377
  inputSchema: {
1299
- outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1300
- spaFallback: z.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
1301
- spaFallbackConfirmed: z.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
1302
- publicConfirmed: z.boolean().optional().describe(
1378
+ outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1379
+ spaFallback: z2.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
1380
+ spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
1381
+ publicConfirmed: z2.boolean().optional().describe(
1303
1382
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1304
1383
  ),
1305
- lang: z.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1384
+ lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1306
1385
  }
1307
1386
  },
1308
1387
  async (args) => {
@@ -1324,7 +1403,10 @@ Next action: ${analysis.suggestedNextAction}`,
1324
1403
  const existing = readSiteFile(ctx.projectDir);
1325
1404
  if (!existing && args.publicConfirmed !== true) {
1326
1405
  return text(
1327
- `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.`
1406
+ "public_deployment_confirmation_required",
1407
+ `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.`,
1408
+ { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
1409
+ "waiting_user"
1328
1410
  );
1329
1411
  }
1330
1412
  ensureUploadSizeWithinLimits(manifest, !existing);
@@ -1350,6 +1432,7 @@ Next action: ${analysis.suggestedNextAction}`,
1350
1432
  apiBaseUrl: ctx.apiBaseUrl
1351
1433
  });
1352
1434
  return text(
1435
+ "site_published",
1353
1436
  `Site published: ${finalized2.url}
1354
1437
  Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1355
1438
  ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
@@ -1357,7 +1440,19 @@ Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1357
1440
  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.
1358
1441
  ` + credentialGitReminder(ctx.projectDir) + (finalized2.warnings.length > 0 ? `
1359
1442
  Warnings:
1360
- ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
1443
+ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
1444
+ {
1445
+ siteId: created.siteId,
1446
+ shortId: created.shortId,
1447
+ url: finalized2.url,
1448
+ deploymentId: created.deploymentId,
1449
+ mode: finalized2.mode,
1450
+ expiresAt: finalized2.expiresAt,
1451
+ filesUploaded: uploaded2,
1452
+ totalBytes: finalized2.totalBytes,
1453
+ warnings: finalized2.warnings,
1454
+ credentialStoredLocally: true
1455
+ }
1361
1456
  );
1362
1457
  }
1363
1458
  const updateOnce = async (forceFullUpload) => {
@@ -1391,6 +1486,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
1391
1486
  const { uploaded, finalized } = update;
1392
1487
  writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
1393
1488
  return text(
1489
+ "site_updated",
1394
1490
  `Site updated: ${finalized.url}
1395
1491
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1396
1492
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
@@ -1398,7 +1494,16 @@ Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1398
1494
  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.
1399
1495
  ` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
1400
1496
  Warnings:
1401
- ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1497
+ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
1498
+ {
1499
+ siteId: existing.siteId,
1500
+ url: finalized.url,
1501
+ mode: finalized.mode,
1502
+ expiresAt: finalized.expiresAt,
1503
+ filesUploaded: uploaded,
1504
+ totalBytes: finalized.totalBytes,
1505
+ warnings: finalized.warnings
1506
+ }
1402
1507
  );
1403
1508
  } catch (e) {
1404
1509
  return toolError(e);
@@ -1409,6 +1514,8 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1409
1514
  "refresh_site",
1410
1515
  {
1411
1516
  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.",
1517
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1518
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1412
1519
  inputSchema: {}
1413
1520
  },
1414
1521
  async () => {
@@ -1416,8 +1523,10 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1416
1523
  const site = requireSiteFile(ctx);
1417
1524
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
1418
1525
  return text(
1526
+ "site_refreshed",
1419
1527
  `Site validity refreshed. New expiry: ${res.expiresAt}
1420
- Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`
1528
+ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`,
1529
+ { siteId: site.siteId, expiresAt: res.expiresAt }
1421
1530
  );
1422
1531
  } catch (e) {
1423
1532
  return toolError(e);
@@ -1428,13 +1537,15 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1428
1537
  "site_status",
1429
1538
  {
1430
1539
  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.",
1540
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1541
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1431
1542
  inputSchema: {}
1432
1543
  },
1433
1544
  async () => {
1434
1545
  try {
1435
1546
  const site = requireSiteFile(ctx);
1436
1547
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
1437
- return textJson("Site status:", res);
1548
+ return textJson("site_status_returned", "Site status:", res);
1438
1549
  } catch (e) {
1439
1550
  return toolError(e);
1440
1551
  }
@@ -1443,40 +1554,42 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
1443
1554
  server.registerTool(
1444
1555
  "subscribe_site",
1445
1556
  {
1446
- 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.`,
1557
+ 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.`,
1558
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1559
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1447
1560
  inputSchema: {
1448
1561
  plan: planEnum.describe(
1449
1562
  "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
1450
- ),
1451
- confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
1563
+ )
1452
1564
  }
1453
1565
  },
1454
1566
  async (args) => {
1455
1567
  try {
1456
1568
  const site = requireSiteFile(ctx);
1457
- const siteUrl = site.url ?? (site.shortId ? `https://${site.shortId}.sakupa.com` : site.siteId);
1458
- if (args.confirm !== true) {
1459
- return text(
1460
- `${subscriptionWarning(siteUrl, args.plan)}
1461
-
1462
- 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.`
1463
- );
1464
- }
1465
1569
  const res = await ctx.client.createPlanCheckout(
1466
1570
  {
1467
1571
  siteId: site.siteId,
1468
1572
  plan: args.plan,
1469
- idempotencyKey: randomUUID(),
1470
- confirmPlan: true
1573
+ idempotencyKey: randomUUID()
1471
1574
  },
1472
1575
  site.credential
1473
1576
  );
1474
1577
  return text(
1578
+ "subscription_checkout_ready",
1475
1579
  `Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
1476
1580
  ${res.checkoutUrl}
1477
1581
 
1478
1582
  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.
1479
- Once payment confirms, the site becomes permanent on its current URL. Binding a custom domain (bind_domain) is optional and still requires DNS verification.`
1583
+ Once payment confirms, the site becomes permanent on its current URL. Binding a custom domain (bind_domain) is optional and still requires DNS verification.`,
1584
+ {
1585
+ siteId: res.siteId,
1586
+ plan: res.plan,
1587
+ monthlyPriceJpy: res.monthlyPriceJpy,
1588
+ checkoutUrl: res.checkoutUrl,
1589
+ sessionId: res.sessionId,
1590
+ finalConfirmationProvider: "stripe"
1591
+ },
1592
+ "waiting_user"
1480
1593
  );
1481
1594
  } catch (e) {
1482
1595
  return toolError(e);
@@ -1487,36 +1600,54 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1487
1600
  "bind_domain",
1488
1601
  {
1489
1602
  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.",
1603
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1604
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1490
1605
  inputSchema: {
1491
- hostname: z.string().describe(
1492
- '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.'
1493
- ),
1494
- verificationId: z.string().optional().describe("Check an existing DNS verification instead of starting a new one.")
1606
+ action: z2.enum(["start", "status"]),
1607
+ hostname: z2.string().optional().describe("Required for start."),
1608
+ verificationId: z2.string().optional().describe("Required for status.")
1495
1609
  }
1496
1610
  },
1497
1611
  async (args) => {
1498
1612
  try {
1499
1613
  const site = requireSiteFile(ctx);
1500
- if (args.verificationId !== void 0) {
1614
+ if (args.action === "status") {
1615
+ if (!args.verificationId) {
1616
+ throw new SakupaError("invalid_request", "verificationId is required for status");
1617
+ }
1501
1618
  const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
1502
1619
  if (res2.status === "verified") {
1503
1620
  writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
1504
1621
  }
1505
1622
  return text(
1623
+ res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
1506
1624
  `DNS verification ${res2.verificationId}: ${res2.status}
1507
1625
  ${res2.message}
1508
1626
  ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
1509
1627
  ` : "") + (res2.pendingDnsRecords.length > 0 ? `
1510
1628
  DNS records still required:
1511
- ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
1629
+ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
1630
+ {
1631
+ verificationId: res2.verificationId,
1632
+ status: res2.status,
1633
+ apexDomain: res2.apexDomain,
1634
+ provisioningJobId: res2.provisioningJobId,
1635
+ pendingDnsRecords: res2.pendingDnsRecords,
1636
+ message: res2.message
1637
+ },
1638
+ res2.status === "verified" ? "completed" : "pending_provider"
1512
1639
  );
1513
1640
  }
1641
+ if (!args.hostname) {
1642
+ throw new SakupaError("invalid_request", "hostname is required for start");
1643
+ }
1514
1644
  const req = {
1515
1645
  siteId: site.siteId,
1516
1646
  hostname: args.hostname
1517
1647
  };
1518
1648
  const res = await ctx.client.bindDomain(site.credential, req);
1519
1649
  return text(
1650
+ "domain_verification_started",
1520
1651
  `Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
1521
1652
 
1522
1653
  1. Prove control of ${res.apexDomain} by creating this DNS record:
@@ -1527,7 +1658,15 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
1527
1658
 
1528
1659
  2. Serving DNS (after verification): ${res.servingInstructions}
1529
1660
 
1530
- Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`
1661
+ Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`,
1662
+ {
1663
+ verificationId: res.verificationId,
1664
+ apexDomain: res.apexDomain,
1665
+ includedHostnames: res.includedHostnames,
1666
+ verificationRecord: res.verificationRecord,
1667
+ servingInstructions: res.servingInstructions
1668
+ },
1669
+ "waiting_user"
1531
1670
  );
1532
1671
  } catch (e) {
1533
1672
  return toolError(e);
@@ -1538,6 +1677,8 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
1538
1677
  "billing_status",
1539
1678
  {
1540
1679
  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).",
1680
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1681
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1541
1682
  inputSchema: {}
1542
1683
  },
1543
1684
  async () => {
@@ -1556,7 +1697,7 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
1556
1697
  res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
1557
1698
  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
1558
1699
  ].filter((l) => l !== void 0);
1559
- return textJson(`${lines.join("\n")}
1700
+ return textJson("billing_status_returned", `${lines.join("\n")}
1560
1701
 
1561
1702
  Full status:`, res);
1562
1703
  } catch (e) {
@@ -1567,41 +1708,53 @@ Full status:`, res);
1567
1708
  server.registerTool(
1568
1709
  "manage_billing",
1569
1710
  {
1570
- 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.",
1711
+ 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.",
1712
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1713
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1571
1714
  inputSchema: {
1572
- siteUrl: z.string().optional().describe(
1573
- "Without .sakupa/site.json only: the remembered https://{shortId}.sakupa.com URL."
1574
- )
1715
+ scope: z2.enum(["site", "public_recovery"])
1575
1716
  }
1576
1717
  },
1577
1718
  async (args) => {
1578
1719
  try {
1579
- const site = readSiteFile(ctx.projectDir);
1580
- if (site) {
1581
- if (args.siteUrl !== void 0) {
1582
- return text(
1583
- "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."
1584
- );
1585
- }
1720
+ if (args.scope === "site") {
1721
+ const site = requireSiteFile(ctx);
1586
1722
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
1587
- return text(
1588
- `Stripe billing portal for this site:
1589
- ${res2.portalUrl}
1590
-
1591
- 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.`
1592
- );
1593
- }
1594
- if (args.siteUrl === void 0) {
1595
- return text(
1596
- "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."
1597
- );
1723
+ return structuredToolResult({
1724
+ schemaVersion: 1,
1725
+ outcome: "waiting_user",
1726
+ resultCode: "site_billing_portal_ready",
1727
+ 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`,
1728
+ data: { scope: args.scope, portalUrl: res2.portalUrl },
1729
+ userAction: {
1730
+ type: "open_url",
1731
+ provider: "stripe",
1732
+ url: res2.portalUrl,
1733
+ expectedOutcome: "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u7BA1\u7406\u4ED8\u6B3E\u65B9\u5F0F\u3001\u53D1\u7968\u6216\u53D6\u6D88\u7EED\u8BA2"
1734
+ },
1735
+ nextActions: [{ tool: "billing_status", allowed: true }]
1736
+ });
1598
1737
  }
1599
- const res = await ctx.client.requestBillingCancellation({ siteUrl: args.siteUrl });
1600
- return text(
1601
- `${res.message}
1602
-
1603
- 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.`
1604
- );
1738
+ const res = await ctx.client.getPublicBillingPortal();
1739
+ return structuredToolResult({
1740
+ schemaVersion: 1,
1741
+ outcome: "waiting_user",
1742
+ resultCode: "public_billing_recovery_portal_ready",
1743
+ 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`,
1744
+ data: {
1745
+ scope: args.scope,
1746
+ portalUrl: res.portalUrl,
1747
+ grantsSiteAuthority: false,
1748
+ acceptsSiteIdentifier: false
1749
+ },
1750
+ userAction: {
1751
+ type: "open_url",
1752
+ provider: "stripe",
1753
+ url: res.portalUrl,
1754
+ 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"
1755
+ },
1756
+ nextActions: []
1757
+ });
1605
1758
  } catch (e) {
1606
1759
  return toolError(e);
1607
1760
  }
@@ -1611,17 +1764,24 @@ For privacy, this response is identical whether or not the URL, site, customer,
1611
1764
  "recover_domain_site",
1612
1765
  {
1613
1766
  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).",
1767
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1768
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1614
1769
  inputSchema: {
1615
- hostname: z.string().describe('Hostname of the site to recover, e.g. "www.example.com".'),
1616
- verificationId: z.string().optional().describe("Complete a recovery previously started for this hostname."),
1617
- preserveExistingCredentials: z.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
1770
+ action: z2.enum(["start", "status", "complete"]),
1771
+ hostname: z2.string().optional().describe("Required for start."),
1772
+ verificationId: z2.string().optional().describe("Required for status or complete."),
1773
+ preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
1618
1774
  }
1619
1775
  },
1620
1776
  async (args) => {
1621
1777
  try {
1622
- if (args.verificationId === void 0) {
1778
+ if (args.action === "start") {
1779
+ if (!args.hostname) {
1780
+ throw new SakupaError("invalid_request", "hostname is required for start");
1781
+ }
1623
1782
  const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
1624
1783
  return text(
1784
+ "domain_recovery_started",
1625
1785
  `Recovery started for ${args.hostname} (apex domain: ${res2.apexDomain}).
1626
1786
 
1627
1787
  Create this DNS record to prove apex-domain control:
@@ -1633,9 +1793,40 @@ ${res2.message}
1633
1793
 
1634
1794
  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.
1635
1795
 
1636
- After the DNS record resolves, re-run recover_domain_site with verificationId: "${res2.verificationId}".`
1796
+ After the DNS record resolves, re-run recover_domain_site with verificationId: "${res2.verificationId}".`,
1797
+ {
1798
+ verificationId: res2.verificationId,
1799
+ apexDomain: res2.apexDomain,
1800
+ verificationRecord: res2.verificationRecord,
1801
+ revokesPreviousCredentialsByDefault: true
1802
+ },
1803
+ "waiting_user"
1804
+ );
1805
+ }
1806
+ if (!args.verificationId) {
1807
+ throw new SakupaError(
1808
+ "invalid_request",
1809
+ "verificationId is required for status or complete"
1637
1810
  );
1638
1811
  }
1812
+ if (args.action === "status") {
1813
+ const res2 = await ctx.client.getRecoveryStatus(args.verificationId);
1814
+ return structuredToolResult({
1815
+ schemaVersion: 1,
1816
+ outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
1817
+ resultCode: res2.status === "expired" ? "domain_recovery_expired" : res2.readyToComplete ? "domain_recovery_ready" : "domain_recovery_pending_dns",
1818
+ summary: `DNS \u6062\u590D\u9A8C\u8BC1\u72B6\u6001\uFF1A${res2.status}`,
1819
+ data: { recovery: res2 },
1820
+ nextActions: [
1821
+ {
1822
+ tool: "recover_domain_site",
1823
+ arguments: { action: "complete", verificationId: args.verificationId },
1824
+ allowed: res2.readyToComplete,
1825
+ ...res2.readyToComplete ? {} : { reasonCode: res2.status }
1826
+ }
1827
+ ]
1828
+ });
1829
+ }
1639
1830
  const res = await ctx.client.completeRecovery(args.verificationId, {
1640
1831
  ...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
1641
1832
  });
@@ -1647,6 +1838,7 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
1647
1838
  apiBaseUrl: ctx.apiBaseUrl
1648
1839
  });
1649
1840
  return text(
1841
+ "domain_recovery_completed",
1650
1842
  `Recovery complete.
1651
1843
  Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
1652
1844
  Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
@@ -1654,54 +1846,16 @@ Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (pr
1654
1846
  A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
1655
1847
  ` + credentialGitReminder(ctx.projectDir) + `
1656
1848
  Download the current site content (signed URL):
1657
- ${res.archiveUrl}`
1658
- );
1659
- } catch (e) {
1660
- return toolError(e);
1661
- }
1662
- }
1663
- );
1664
- server.registerTool(
1665
- "set_billing_plan",
1666
- {
1667
- 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.`,
1668
- inputSchema: {
1669
- plan: planEnum.optional().describe("Target monthly plan."),
1670
- cancelRenewal: z.boolean().optional().describe(
1671
- "true: cancel renewal (the site stays permanent to the end of the paid month, then reverts to free). false: re-enable renewal."
1672
- ),
1673
- confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
1674
- }
1675
- },
1676
- async (args) => {
1677
- try {
1678
- const site = requireSiteFile(ctx);
1679
- const req = {
1680
- ...args.plan !== void 0 ? { plan: args.plan } : {},
1681
- ...args.cancelRenewal !== void 0 ? { cancelRenewal: args.cancelRenewal } : {},
1682
- confirm: args.confirm === true
1683
- };
1684
- try {
1685
- const res = await ctx.client.setBillingPlan(site.siteId, site.credential, req);
1686
- return textJson(
1687
- `Billing updated for site ${res.siteId} (mode: ${res.mode}).
1688
- Consequences:
1689
- ${res.consequences.map((c) => `- ${c}`).join("\n")}
1690
- Result:`,
1691
- res
1692
- );
1693
- } catch (e) {
1694
- if (isSakupaError(e) && e.code === "confirmation_required") {
1695
- return text(
1696
- `Confirmation required before changing the billing plan \u2014 nothing was applied.
1697
-
1698
- ${e.message}
1699
- ` + (e.details !== void 0 ? `${JSON.stringify(e.details, null, 2)}
1700
- ` : "") + "\nPlease show these consequences to the user and, after their explicit confirmation, re-run set_billing_plan with the same arguments plus confirm: true."
1701
- );
1849
+ ${res.archiveUrl}`,
1850
+ {
1851
+ siteId: res.siteId,
1852
+ boundHostnames: res.boundHostnames,
1853
+ revokedPreviousCredentials: res.revokedPreviousCredentials,
1854
+ archiveUrl: res.archiveUrl,
1855
+ archiveExpiresAt: res.archiveExpiresAt,
1856
+ credentialStoredLocally: true
1702
1857
  }
1703
- throw e;
1704
- }
1858
+ );
1705
1859
  } catch (e) {
1706
1860
  return toolError(e);
1707
1861
  }
@@ -1711,11 +1865,13 @@ ${e.message}
1711
1865
  "create_support_ticket",
1712
1866
  {
1713
1867
  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.",
1868
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1869
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1714
1870
  inputSchema: {
1715
1871
  category: ticketCategoryEnum,
1716
- subject: z.string().describe("Short subject line."),
1717
- description: z.string().describe("Problem description (no secrets, no card data)."),
1718
- contactEmail: z.string().optional().describe("Optional contact email for follow-up.")
1872
+ subject: z2.string().describe("Short subject line."),
1873
+ description: z2.string().describe("Problem description (no secrets, no card data)."),
1874
+ contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
1719
1875
  }
1720
1876
  },
1721
1877
  async (args) => {
@@ -1728,7 +1884,11 @@ ${e.message}
1728
1884
  description: args.description,
1729
1885
  ...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
1730
1886
  });
1731
- return text(`Support ticket created: ${res.ticketId} (status: ${res.status}).`);
1887
+ return text(
1888
+ "support_ticket_created",
1889
+ `Support ticket created: ${res.ticketId} (status: ${res.status}).`,
1890
+ { ticketId: res.ticketId, status: res.status }
1891
+ );
1732
1892
  } catch (e) {
1733
1893
  return toolError(e);
1734
1894
  }
@@ -1738,15 +1898,17 @@ ${e.message}
1738
1898
  "report_bug",
1739
1899
  {
1740
1900
  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.",
1901
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1902
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1741
1903
  inputSchema: {
1742
- toolName: z.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
1743
- errorCode: z.string().optional(),
1744
- errorMessage: z.string().optional().describe("Sanitized error message (no secrets)."),
1745
- requestId: z.string().optional(),
1746
- deploymentId: z.string().optional(),
1904
+ toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
1905
+ errorCode: z2.string().optional(),
1906
+ errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
1907
+ requestId: z2.string().optional(),
1908
+ deploymentId: z2.string().optional(),
1747
1909
  severity: severityEnum.optional(),
1748
- description: z.string().optional().describe("What happened, in the user's words (no secrets)."),
1749
- confirmSubmit: z.boolean().optional().describe("User reviewed the report payload and approved submission.")
1910
+ description: z2.string().optional().describe("What happened, in the user's words (no secrets)."),
1911
+ confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
1750
1912
  }
1751
1913
  },
1752
1914
  async (args) => {
@@ -1772,14 +1934,18 @@ ${e.message}
1772
1934
  };
1773
1935
  if (args.confirmSubmit !== true) {
1774
1936
  return textJson(
1937
+ "bug_report_preview_ready",
1775
1938
  "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.",
1776
- payload
1939
+ payload,
1940
+ "preview"
1777
1941
  );
1778
1942
  }
1779
1943
  const res = await ctx.client.reportBug(payload, site?.credential);
1780
1944
  return text(
1945
+ "bug_report_submitted",
1781
1946
  `Bug report submitted. Ticket: ${res.ticketId}
1782
- Summary: ${res.sanitizedSummary}`
1947
+ Summary: ${res.sanitizedSummary}`,
1948
+ { ticketId: res.ticketId, sanitizedSummary: res.sanitizedSummary }
1783
1949
  );
1784
1950
  } catch (e) {
1785
1951
  return toolError(e);
@@ -1788,6 +1954,132 @@ Summary: ${res.sanitizedSummary}`
1788
1954
  );
1789
1955
  }
1790
1956
 
1957
+ // src/tools/billing.ts
1958
+ import { z as z3 } from "zod";
1959
+ var plan = z3.enum(["water", "personal", "share", "business"]);
1960
+ var trigger = z3.enum(["actual_overage", "credential_automation"]);
1961
+ function registerBillingTools(server, ctx) {
1962
+ server.registerTool(
1963
+ "list_billing_plans",
1964
+ {
1965
+ 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.",
1966
+ inputSchema: {},
1967
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
1968
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
1969
+ },
1970
+ async () => {
1971
+ try {
1972
+ const catalog = await ctx.client.getBillingPlanCatalog();
1973
+ return structuredToolResult({
1974
+ schemaVersion: 1,
1975
+ outcome: "completed",
1976
+ resultCode: "billing_catalog_returned",
1977
+ 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`,
1978
+ data: { catalog },
1979
+ nextActions: [{ tool: "subscribe_site", allowed: true }]
1980
+ });
1981
+ } catch (error) {
1982
+ return toolError(error);
1983
+ }
1984
+ }
1985
+ );
1986
+ server.registerTool(
1987
+ "manage_subscription",
1988
+ {
1989
+ 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.",
1990
+ inputSchema: {
1991
+ action: z3.enum([
1992
+ "open_plan_change",
1993
+ "create_automation_authorization",
1994
+ "disable_automation",
1995
+ "request_authorized_upgrade"
1996
+ ]),
1997
+ operationId: z3.string().min(1).describe("Stable idempotency key chosen by the caller."),
1998
+ targetPlan: plan.optional(),
1999
+ maxPlan: plan.optional(),
2000
+ allowedTriggers: z3.array(trigger).min(1).optional(),
2001
+ observedSnapshotId: z3.string().optional(),
2002
+ observedCatalogVersion: z3.string().optional()
2003
+ },
2004
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2005
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
2006
+ },
2007
+ async (args) => {
2008
+ try {
2009
+ const site = requireSiteFile(ctx);
2010
+ let request;
2011
+ if (args.action === "open_plan_change") {
2012
+ if (!args.targetPlan) {
2013
+ throw new SakupaError("invalid_request", "targetPlan is required for open_plan_change");
2014
+ }
2015
+ request = {
2016
+ action: args.action,
2017
+ targetPlan: args.targetPlan,
2018
+ operationId: args.operationId
2019
+ };
2020
+ } else if (args.action === "create_automation_authorization") {
2021
+ if (!args.maxPlan || !args.allowedTriggers) {
2022
+ throw new SakupaError(
2023
+ "invalid_request",
2024
+ "maxPlan and allowedTriggers are required for authorization"
2025
+ );
2026
+ }
2027
+ request = {
2028
+ action: args.action,
2029
+ maxPlan: args.maxPlan,
2030
+ allowedTriggers: args.allowedTriggers,
2031
+ operationId: args.operationId
2032
+ };
2033
+ } else if (args.action === "disable_automation") {
2034
+ request = { action: args.action, operationId: args.operationId };
2035
+ } else {
2036
+ if (!args.targetPlan) {
2037
+ throw new SakupaError(
2038
+ "invalid_request",
2039
+ "targetPlan is required for request_authorized_upgrade"
2040
+ );
2041
+ }
2042
+ request = {
2043
+ action: args.action,
2044
+ operationId: args.operationId,
2045
+ targetPlan: args.targetPlan,
2046
+ trigger: "credential_automation",
2047
+ ...args.observedSnapshotId ? { observedSnapshotId: args.observedSnapshotId } : {},
2048
+ ...args.observedCatalogVersion ? { observedCatalogVersion: args.observedCatalogVersion } : {}
2049
+ };
2050
+ }
2051
+ const result = await ctx.client.manageSubscription(site.siteId, site.credential, request);
2052
+ const portalUrl = "portalUrl" in result ? result.portalUrl : void 0;
2053
+ const authorizationUrl = "authorizationUrl" in result ? result.authorizationUrl : void 0;
2054
+ const executionOutcome = "outcome" in result ? result.outcome : void 0;
2055
+ const outcome = portalUrl || authorizationUrl ? "waiting_user" : executionOutcome === "pending_provider" ? "pending_provider" : executionOutcome === "blocked" ? "blocked" : "completed";
2056
+ 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";
2057
+ const url = portalUrl ?? authorizationUrl;
2058
+ return structuredToolResult({
2059
+ schemaVersion: 1,
2060
+ outcome,
2061
+ resultCode,
2062
+ operationId: args.operationId,
2063
+ 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}`,
2064
+ data: { action: args.action, result },
2065
+ ...url ? {
2066
+ userAction: {
2067
+ type: "open_url",
2068
+ provider: portalUrl ? "stripe" : "sakupa",
2069
+ url,
2070
+ ..."expiresAt" in result && typeof result.expiresAt === "string" ? { expiresAt: result.expiresAt } : {},
2071
+ 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"
2072
+ }
2073
+ } : {},
2074
+ nextActions: [{ tool: "billing_status", allowed: true }]
2075
+ });
2076
+ } catch (error) {
2077
+ return toolError(error);
2078
+ }
2079
+ }
2080
+ );
2081
+ }
2082
+
1791
2083
  // src/transport.ts
1792
2084
  var FetchTransport = class {
1793
2085
  baseUrl;
@@ -1853,14 +2145,17 @@ Workflow:
1853
2145
  management credential in .sakupa/site.json. Deploying again updates the site and refreshes
1854
2146
  its validity; refresh_site extends validity without uploading.
1855
2147
  3. To make the site PERMANENT, subscribe it to a monthly hosting plan (subscribe_site ->
1856
- Stripe-hosted checkout; water/personal/share/business, auto-upgrade when the site outgrows
1857
- its plan). Paying makes the {shortId}.sakupa.com URL permanent \u2014 that is what payment buys.
2148
+ Stripe-hosted checkout; water/personal/share/business). Paying makes the
2149
+ {shortId}.sakupa.com URL permanent \u2014 that is what payment buys. Usage over the chosen plan
2150
+ shows an over-limit notice by default. Automatic upgrades require a separate, bounded,
2151
+ one-time Sakupa authorization and can never exceed the user-approved plan cap.
1858
2152
  4. Optionally bind a custom domain to the subscribed site (bind_domain): an included extra
1859
2153
  serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
1860
2154
  first verified request wins; unverified requests expire after 72 hours. billing_status,
1861
- set_billing_plan, manage_billing and recover_domain_site manage the paid lifecycle.
1862
- An immediate Stripe cancellation reverts the site to a free 24h site and removes all paid
1863
- data when Sakupa receives the signed cancellation webhook.
2155
+ manage_subscription, manage_billing and recover_domain_site manage the paid lifecycle. Manual plan
2156
+ changes are confirmed only on Stripe Customer Portal and synchronized by Stripe webhook.
2157
+ A cancellation keeps the site paid through the current period. Sakupa reverts it to a free
2158
+ 24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
1864
2159
 
1865
2160
  Safety boundaries:
1866
2161
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
@@ -1868,9 +2163,9 @@ Safety boundaries:
1868
2163
  - Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
1869
2164
  - A subscription never grants domain ownership; only DNS verification does.
1870
2165
  - The management credential lives only in .sakupa/site.json; never share or upload it. Without
1871
- a bound custom domain, a lost credential is unrecoverable by design. manage_billing can accept
1872
- the remembered Sakupa URL and request a one-time cancellation link sent only to the exact
1873
- subscription's Stripe billing email; it never restores site authority.`;
2166
+ a bound custom domain, a lost credential is unrecoverable by design. manage_billing then opens
2167
+ Stripe's public no-code portal login, where the customer verifies the checkout email with a
2168
+ Stripe one-time passcode; it never restores site authority.`;
1874
2169
  function createSakupaMcpServer(opts) {
1875
2170
  const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
1876
2171
  const server = new McpServer(
@@ -1882,6 +2177,11 @@ function createSakupaMcpServer(opts) {
1882
2177
  projectDir: opts.projectDir,
1883
2178
  apiBaseUrl: opts.apiBaseUrl
1884
2179
  });
2180
+ registerBillingTools(server, {
2181
+ client,
2182
+ projectDir: opts.projectDir,
2183
+ apiBaseUrl: opts.apiBaseUrl
2184
+ });
1885
2185
  return server;
1886
2186
  }
1887
2187