@sakupa/mcp 0.5.0 → 0.7.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.
- package/dist/bin.js +594 -177
- package/dist/index.js +591 -177
- 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.
|
|
124
|
+
var SAKUPA_MCP_VERSION = "0.7.0";
|
|
125
125
|
|
|
126
126
|
// ../core/dist/domain/errors.js
|
|
127
127
|
var HTTP_STATUS = {
|
|
@@ -446,9 +446,6 @@ 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.";
|
|
451
|
-
|
|
452
449
|
// ../core/dist/dto.js
|
|
453
450
|
var CREDENTIAL_HEADER = "x-sakupa-credential";
|
|
454
451
|
var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
|
|
@@ -461,9 +458,6 @@ var utf8Encoder = new TextEncoder();
|
|
|
461
458
|
// ../core/dist/services/subscriptions.js
|
|
462
459
|
var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
|
|
463
460
|
|
|
464
|
-
// ../core/dist/services/billing-cancellation.js
|
|
465
|
-
var encoder = new TextEncoder();
|
|
466
|
-
|
|
467
461
|
// src/version.ts
|
|
468
462
|
var MCP_VERSION = SAKUPA_MCP_VERSION;
|
|
469
463
|
var CLIENT_TYPE = "sakupa-mcp";
|
|
@@ -613,8 +607,18 @@ var HttpApiClient = class {
|
|
|
613
607
|
credential
|
|
614
608
|
});
|
|
615
609
|
}
|
|
616
|
-
async deleteSite(siteId, credential) {
|
|
617
|
-
|
|
610
|
+
async deleteSite(siteId, credential, req) {
|
|
611
|
+
return this.call("POST", `/v1/sites/${encodeURIComponent(siteId)}/delete`, {
|
|
612
|
+
credential,
|
|
613
|
+
body: req
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
async previewDeleteSite(siteId, credential, req) {
|
|
617
|
+
return this.call(
|
|
618
|
+
"POST",
|
|
619
|
+
`/v1/sites/${encodeURIComponent(siteId)}/delete/preview`,
|
|
620
|
+
{ credential, body: req }
|
|
621
|
+
);
|
|
618
622
|
}
|
|
619
623
|
async bindDomain(credential, req) {
|
|
620
624
|
return this.call("POST", "/v1/domains/bind", { credential, body: req });
|
|
@@ -626,9 +630,29 @@ var HttpApiClient = class {
|
|
|
626
630
|
{ credential }
|
|
627
631
|
);
|
|
628
632
|
}
|
|
633
|
+
async unbindDomain(siteId, credential, req) {
|
|
634
|
+
return this.call(
|
|
635
|
+
"POST",
|
|
636
|
+
`/v1/sites/${encodeURIComponent(siteId)}/domain/unbind`,
|
|
637
|
+
{ credential, body: req }
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
async previewUnbindDomain(siteId, credential, req) {
|
|
641
|
+
return this.call(
|
|
642
|
+
"POST",
|
|
643
|
+
`/v1/sites/${encodeURIComponent(siteId)}/domain/unbind/preview`,
|
|
644
|
+
{ credential, body: req }
|
|
645
|
+
);
|
|
646
|
+
}
|
|
629
647
|
async recoverDomain(req) {
|
|
630
648
|
return this.call("POST", "/v1/domains/recover", { body: req });
|
|
631
649
|
}
|
|
650
|
+
async getRecoveryStatus(verificationId) {
|
|
651
|
+
return this.call(
|
|
652
|
+
"GET",
|
|
653
|
+
`/v1/domains/recover/${encodeURIComponent(verificationId)}`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
632
656
|
async completeRecovery(verificationId, req) {
|
|
633
657
|
return this.call(
|
|
634
658
|
"POST",
|
|
@@ -659,19 +683,18 @@ var HttpApiClient = class {
|
|
|
659
683
|
{ credential }
|
|
660
684
|
);
|
|
661
685
|
}
|
|
662
|
-
async
|
|
686
|
+
async getBillingPlanCatalog() {
|
|
687
|
+
return this.call("GET", "/v1/billing/plans");
|
|
688
|
+
}
|
|
689
|
+
async changeSubscriptionPlan(credential, req) {
|
|
663
690
|
return this.call(
|
|
664
691
|
"POST",
|
|
665
|
-
`/v1/sites/${encodeURIComponent(siteId)}/billing/plan`,
|
|
692
|
+
`/v1/sites/${encodeURIComponent(req.siteId)}/billing/plan-change`,
|
|
666
693
|
{ credential, body: req }
|
|
667
694
|
);
|
|
668
695
|
}
|
|
669
|
-
async
|
|
670
|
-
return this.call(
|
|
671
|
-
"POST",
|
|
672
|
-
"/v1/billing/cancellation-requests",
|
|
673
|
-
{ body: req }
|
|
674
|
-
);
|
|
696
|
+
async getPublicBillingPortal() {
|
|
697
|
+
return this.call("GET", "/v1/billing/portal");
|
|
675
698
|
}
|
|
676
699
|
async createTicket(credential, req) {
|
|
677
700
|
return this.call("POST", "/v1/support/tickets", {
|
|
@@ -1178,6 +1201,47 @@ async function analyzeProject(projectDir, opts = {}) {
|
|
|
1178
1201
|
};
|
|
1179
1202
|
}
|
|
1180
1203
|
|
|
1204
|
+
// src/tools/result.ts
|
|
1205
|
+
import { z } from "zod";
|
|
1206
|
+
var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
|
|
1207
|
+
schemaVersion: z.literal(1),
|
|
1208
|
+
outcome: z.enum([
|
|
1209
|
+
"completed",
|
|
1210
|
+
"preview",
|
|
1211
|
+
"waiting_user",
|
|
1212
|
+
"pending_provider",
|
|
1213
|
+
"blocked",
|
|
1214
|
+
"expired",
|
|
1215
|
+
"failed"
|
|
1216
|
+
]),
|
|
1217
|
+
resultCode: z.string(),
|
|
1218
|
+
operationId: z.string().optional(),
|
|
1219
|
+
summary: z.string(),
|
|
1220
|
+
data: z.record(z.string(), z.unknown()),
|
|
1221
|
+
userAction: z.object({
|
|
1222
|
+
type: z.enum(["open_url", "confirm_in_mcp", "configure_dns"]),
|
|
1223
|
+
provider: z.enum(["stripe", "sakupa"]).optional(),
|
|
1224
|
+
url: z.string().optional(),
|
|
1225
|
+
expiresAt: z.string().optional(),
|
|
1226
|
+
expectedOutcome: z.string(),
|
|
1227
|
+
resumeWith: z.object({ tool: z.string(), arguments: z.record(z.string(), z.unknown()) }).optional()
|
|
1228
|
+
}).optional(),
|
|
1229
|
+
nextActions: z.array(
|
|
1230
|
+
z.object({
|
|
1231
|
+
tool: z.string(),
|
|
1232
|
+
arguments: z.record(z.string(), z.unknown()).optional(),
|
|
1233
|
+
allowed: z.boolean(),
|
|
1234
|
+
reasonCode: z.string().optional()
|
|
1235
|
+
})
|
|
1236
|
+
)
|
|
1237
|
+
};
|
|
1238
|
+
function structuredToolResult(envelope) {
|
|
1239
|
+
return {
|
|
1240
|
+
content: [{ type: "text", text: envelope.summary }],
|
|
1241
|
+
structuredContent: envelope
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1181
1245
|
// src/tools/context.ts
|
|
1182
1246
|
function requireSiteFile(ctx) {
|
|
1183
1247
|
const file = readSiteFile(ctx.projectDir);
|
|
@@ -1190,39 +1254,71 @@ function requireSiteFile(ctx) {
|
|
|
1190
1254
|
return file;
|
|
1191
1255
|
}
|
|
1192
1256
|
function toolError(e) {
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1257
|
+
const errorCode = isSakupaError(e) ? e.code : "internal";
|
|
1258
|
+
const retryable = errorCode === "rate_limited" || errorCode === "internal";
|
|
1259
|
+
const safeDetailKeys = /* @__PURE__ */ new Set([
|
|
1260
|
+
"retryAfterSeconds",
|
|
1261
|
+
"reasonCode",
|
|
1262
|
+
"currentStatus",
|
|
1263
|
+
"expectedStatus",
|
|
1264
|
+
"minimumVersion",
|
|
1265
|
+
"currentVersion"
|
|
1266
|
+
]);
|
|
1267
|
+
const rawDetails = isSakupaError(e) && e.details && typeof e.details === "object" ? e.details : void 0;
|
|
1268
|
+
const safeDetails = rawDetails ? Object.fromEntries(
|
|
1269
|
+
Object.entries(rawDetails).filter(
|
|
1270
|
+
([key, value]) => safeDetailKeys.has(key) && (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
1271
|
+
)
|
|
1272
|
+
) : void 0;
|
|
1273
|
+
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" : errorCode === "confirmation_required" ? "\u8D44\u6E90\u6216\u8D26\u5355\u72B6\u6001\u5DF2\u53D8\u5316\uFF0C\u65E7\u786E\u8BA4\u5DF2\u5931\u6548\uFF1B\u8BF7\u91CD\u65B0\u9884\u89C8\u540E\u518D\u786E\u8BA4\u3002" : errorCode === "payment_required" ? "\u8BE5\u64CD\u4F5C\u9700\u8981\u6709\u6548\u8BA2\u9605\uFF1B\u8BF7\u5148\u67E5\u8BE2\u8D26\u5355\u72B6\u6001\u3002" : errorCode === "rate_limited" ? "\u8BF7\u6C42\u9891\u7387\u5DF2\u8FBE\u5230\u670D\u52A1\u7AEF\u4E0A\u9650\uFF0C\u8BF7\u6309\u8FD4\u56DE\u7684\u7B49\u5F85\u65F6\u95F4\u540E\u91CD\u8BD5\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";
|
|
1274
|
+
const result = structuredToolResult({
|
|
1275
|
+
schemaVersion: 1,
|
|
1276
|
+
outcome: "failed",
|
|
1277
|
+
resultCode: `error_${errorCode}`,
|
|
1278
|
+
summary: safeSummary,
|
|
1279
|
+
data: {
|
|
1280
|
+
errorCode,
|
|
1281
|
+
retryable,
|
|
1282
|
+
...safeDetails && Object.keys(safeDetails).length > 0 ? { details: safeDetails } : {}
|
|
1283
|
+
},
|
|
1284
|
+
nextActions: []
|
|
1285
|
+
});
|
|
1286
|
+
return { ...result, isError: true };
|
|
1203
1287
|
}
|
|
1204
1288
|
|
|
1205
1289
|
// src/tools/definitions.ts
|
|
1206
1290
|
import { randomUUID } from "node:crypto";
|
|
1207
1291
|
import { promises as fs2 } from "node:fs";
|
|
1208
1292
|
import { join as join3, resolve as resolve2 } from "node:path";
|
|
1209
|
-
import { z } from "zod";
|
|
1210
|
-
function text(t) {
|
|
1211
|
-
return {
|
|
1293
|
+
import { z as z2 } from "zod";
|
|
1294
|
+
function text(resultCode, t, data = {}, outcome = "completed") {
|
|
1295
|
+
return structuredToolResult({
|
|
1296
|
+
schemaVersion: 1,
|
|
1297
|
+
outcome,
|
|
1298
|
+
resultCode,
|
|
1299
|
+
summary: t,
|
|
1300
|
+
data,
|
|
1301
|
+
nextActions: []
|
|
1302
|
+
});
|
|
1212
1303
|
}
|
|
1213
|
-
function textJson(header, obj) {
|
|
1214
|
-
|
|
1215
|
-
${JSON.stringify(obj, null, 2)}
|
|
1304
|
+
function textJson(resultCode, header, obj, outcome = "completed") {
|
|
1305
|
+
const summary = `${header}
|
|
1306
|
+
${JSON.stringify(obj, null, 2)}`;
|
|
1307
|
+
return structuredToolResult({
|
|
1308
|
+
schemaVersion: 1,
|
|
1309
|
+
outcome,
|
|
1310
|
+
resultCode,
|
|
1311
|
+
summary,
|
|
1312
|
+
data: typeof obj === "object" && obj !== null ? { result: obj } : { result: obj },
|
|
1313
|
+
nextActions: []
|
|
1314
|
+
});
|
|
1216
1315
|
}
|
|
1217
|
-
var planEnum =
|
|
1218
|
-
var severityEnum =
|
|
1316
|
+
var planEnum = z2.enum(["water", "personal", "share", "business"]);
|
|
1317
|
+
var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
|
|
1219
1318
|
function planCatalog() {
|
|
1220
1319
|
return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
|
|
1221
1320
|
}
|
|
1222
|
-
|
|
1223
|
-
return SUBSCRIPTION_WARNING_TEXT.replaceAll("{siteUrl}", siteUrl).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
|
|
1224
|
-
}
|
|
1225
|
-
var ticketCategoryEnum = z.enum([
|
|
1321
|
+
var ticketCategoryEnum = z2.enum([
|
|
1226
1322
|
"billing",
|
|
1227
1323
|
"payment",
|
|
1228
1324
|
"refund_review",
|
|
@@ -1240,14 +1336,17 @@ function analysisSummary(analysis) {
|
|
|
1240
1336
|
}
|
|
1241
1337
|
function notDeployableResult(analysis) {
|
|
1242
1338
|
return textJson(
|
|
1339
|
+
"site_analysis_not_deployable",
|
|
1243
1340
|
`This project is NOT deployable as-is. No files were uploaded and no API call was made.
|
|
1244
1341
|
Next action: ${analysis.suggestedNextAction}
|
|
1245
1342
|
Analysis:`,
|
|
1246
|
-
analysisSummary(analysis)
|
|
1343
|
+
analysisSummary(analysis),
|
|
1344
|
+
"blocked"
|
|
1247
1345
|
);
|
|
1248
1346
|
}
|
|
1249
1347
|
function spaConfirmationResult(analysis) {
|
|
1250
1348
|
return text(
|
|
1349
|
+
"spa_fallback_confirmation_required",
|
|
1251
1350
|
`SPA fallback confirmation required \u2014 nothing was deployed yet.
|
|
1252
1351
|
|
|
1253
1352
|
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 +1355,9 @@ Please ask the user to choose, then re-run deploy_site with:
|
|
|
1256
1355
|
- spaFallback: true, spaFallbackConfirmed: true -> enable SPA fallback
|
|
1257
1356
|
- spaFallbackConfirmed: true (spaFallback omitted or false) -> deploy WITHOUT fallback (unknown paths return 404)
|
|
1258
1357
|
|
|
1259
|
-
Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files
|
|
1358
|
+
Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`,
|
|
1359
|
+
{ analysis: analysisSummary(analysis), requestedConfirmation: "spa_fallback" },
|
|
1360
|
+
"waiting_user"
|
|
1260
1361
|
);
|
|
1261
1362
|
}
|
|
1262
1363
|
var MB2 = 1024 * 1024;
|
|
@@ -1316,10 +1417,12 @@ function registerTools(server, ctx) {
|
|
|
1316
1417
|
"analyze_site",
|
|
1317
1418
|
{
|
|
1318
1419
|
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.",
|
|
1420
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1421
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
1319
1422
|
inputSchema: {
|
|
1320
|
-
outputDir:
|
|
1321
|
-
spaFallbackRequested:
|
|
1322
|
-
spaFallbackConfirmed:
|
|
1423
|
+
outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
|
|
1424
|
+
spaFallbackRequested: z2.boolean().optional().describe("User asked for SPA fallback (unknown paths rewritten to index.html)."),
|
|
1425
|
+
spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change.")
|
|
1323
1426
|
}
|
|
1324
1427
|
},
|
|
1325
1428
|
async (args) => {
|
|
@@ -1330,6 +1433,7 @@ function registerTools(server, ctx) {
|
|
|
1330
1433
|
...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
|
|
1331
1434
|
});
|
|
1332
1435
|
return textJson(
|
|
1436
|
+
"site_analysis_completed",
|
|
1333
1437
|
`Analysis of ${ctx.projectDir}
|
|
1334
1438
|
Next action: ${analysis.suggestedNextAction}`,
|
|
1335
1439
|
analysisSummary(analysis)
|
|
@@ -1343,14 +1447,16 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1343
1447
|
"deploy_site",
|
|
1344
1448
|
{
|
|
1345
1449
|
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.`,
|
|
1450
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1451
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1346
1452
|
inputSchema: {
|
|
1347
|
-
outputDir:
|
|
1348
|
-
spaFallback:
|
|
1349
|
-
spaFallbackConfirmed:
|
|
1350
|
-
publicConfirmed:
|
|
1453
|
+
outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
|
|
1454
|
+
spaFallback: z2.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
|
|
1455
|
+
spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
|
|
1456
|
+
publicConfirmed: z2.boolean().optional().describe(
|
|
1351
1457
|
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
1352
1458
|
),
|
|
1353
|
-
lang:
|
|
1459
|
+
lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
1354
1460
|
}
|
|
1355
1461
|
},
|
|
1356
1462
|
async (args) => {
|
|
@@ -1372,7 +1478,10 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1372
1478
|
const existing = readSiteFile(ctx.projectDir);
|
|
1373
1479
|
if (!existing && args.publicConfirmed !== true) {
|
|
1374
1480
|
return text(
|
|
1375
|
-
|
|
1481
|
+
"public_deployment_confirmation_required",
|
|
1482
|
+
`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.`,
|
|
1483
|
+
{ publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
|
|
1484
|
+
"waiting_user"
|
|
1376
1485
|
);
|
|
1377
1486
|
}
|
|
1378
1487
|
ensureUploadSizeWithinLimits(manifest, !existing);
|
|
@@ -1398,6 +1507,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1398
1507
|
apiBaseUrl: ctx.apiBaseUrl
|
|
1399
1508
|
});
|
|
1400
1509
|
return text(
|
|
1510
|
+
"site_published",
|
|
1401
1511
|
`Site published: ${finalized2.url}
|
|
1402
1512
|
Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
1403
1513
|
` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
|
|
@@ -1405,7 +1515,19 @@ Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
|
1405
1515
|
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
1516
|
` + credentialGitReminder(ctx.projectDir) + (finalized2.warnings.length > 0 ? `
|
|
1407
1517
|
Warnings:
|
|
1408
|
-
${JSON.stringify(finalized2.warnings, null, 2)}` : "")
|
|
1518
|
+
${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
1519
|
+
{
|
|
1520
|
+
siteId: created.siteId,
|
|
1521
|
+
shortId: created.shortId,
|
|
1522
|
+
url: finalized2.url,
|
|
1523
|
+
deploymentId: created.deploymentId,
|
|
1524
|
+
mode: finalized2.mode,
|
|
1525
|
+
expiresAt: finalized2.expiresAt,
|
|
1526
|
+
filesUploaded: uploaded2,
|
|
1527
|
+
totalBytes: finalized2.totalBytes,
|
|
1528
|
+
warnings: finalized2.warnings,
|
|
1529
|
+
credentialStoredLocally: true
|
|
1530
|
+
}
|
|
1409
1531
|
);
|
|
1410
1532
|
}
|
|
1411
1533
|
const updateOnce = async (forceFullUpload) => {
|
|
@@ -1439,6 +1561,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
|
|
|
1439
1561
|
const { uploaded, finalized } = update;
|
|
1440
1562
|
writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
|
|
1441
1563
|
return text(
|
|
1564
|
+
"site_updated",
|
|
1442
1565
|
`Site updated: ${finalized.url}
|
|
1443
1566
|
Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
1444
1567
|
` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
|
|
@@ -1446,7 +1569,16 @@ Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
|
1446
1569
|
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
1570
|
` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
|
|
1448
1571
|
Warnings:
|
|
1449
|
-
${JSON.stringify(finalized.warnings, null, 2)}` : "")
|
|
1572
|
+
${JSON.stringify(finalized.warnings, null, 2)}` : ""),
|
|
1573
|
+
{
|
|
1574
|
+
siteId: existing.siteId,
|
|
1575
|
+
url: finalized.url,
|
|
1576
|
+
mode: finalized.mode,
|
|
1577
|
+
expiresAt: finalized.expiresAt,
|
|
1578
|
+
filesUploaded: uploaded,
|
|
1579
|
+
totalBytes: finalized.totalBytes,
|
|
1580
|
+
warnings: finalized.warnings
|
|
1581
|
+
}
|
|
1450
1582
|
);
|
|
1451
1583
|
} catch (e) {
|
|
1452
1584
|
return toolError(e);
|
|
@@ -1457,6 +1589,8 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
|
|
|
1457
1589
|
"refresh_site",
|
|
1458
1590
|
{
|
|
1459
1591
|
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.",
|
|
1592
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1593
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1460
1594
|
inputSchema: {}
|
|
1461
1595
|
},
|
|
1462
1596
|
async () => {
|
|
@@ -1464,8 +1598,10 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
|
|
|
1464
1598
|
const site = requireSiteFile(ctx);
|
|
1465
1599
|
const res = await ctx.client.refreshSite(site.siteId, site.credential);
|
|
1466
1600
|
return text(
|
|
1601
|
+
"site_refreshed",
|
|
1467
1602
|
`Site validity refreshed. New expiry: ${res.expiresAt}
|
|
1468
|
-
Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh
|
|
1603
|
+
Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`,
|
|
1604
|
+
{ siteId: site.siteId, expiresAt: res.expiresAt }
|
|
1469
1605
|
);
|
|
1470
1606
|
} catch (e) {
|
|
1471
1607
|
return toolError(e);
|
|
@@ -1476,13 +1612,15 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
|
|
|
1476
1612
|
"site_status",
|
|
1477
1613
|
{
|
|
1478
1614
|
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.",
|
|
1615
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1616
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
1479
1617
|
inputSchema: {}
|
|
1480
1618
|
},
|
|
1481
1619
|
async () => {
|
|
1482
1620
|
try {
|
|
1483
1621
|
const site = requireSiteFile(ctx);
|
|
1484
1622
|
const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
|
|
1485
|
-
return textJson("Site status:", res);
|
|
1623
|
+
return textJson("site_status_returned", "Site status:", res);
|
|
1486
1624
|
} catch (e) {
|
|
1487
1625
|
return toolError(e);
|
|
1488
1626
|
}
|
|
@@ -1491,40 +1629,42 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
|
|
|
1491
1629
|
server.registerTool(
|
|
1492
1630
|
"subscribe_site",
|
|
1493
1631
|
{
|
|
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
|
|
1632
|
+
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.`,
|
|
1633
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1634
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1495
1635
|
inputSchema: {
|
|
1496
1636
|
plan: planEnum.describe(
|
|
1497
1637
|
"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.")
|
|
1638
|
+
)
|
|
1500
1639
|
}
|
|
1501
1640
|
},
|
|
1502
1641
|
async (args) => {
|
|
1503
1642
|
try {
|
|
1504
1643
|
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
1644
|
const res = await ctx.client.createPlanCheckout(
|
|
1514
1645
|
{
|
|
1515
1646
|
siteId: site.siteId,
|
|
1516
1647
|
plan: args.plan,
|
|
1517
|
-
idempotencyKey: randomUUID()
|
|
1518
|
-
confirmPlan: true
|
|
1648
|
+
idempotencyKey: randomUUID()
|
|
1519
1649
|
},
|
|
1520
1650
|
site.credential
|
|
1521
1651
|
);
|
|
1522
1652
|
return text(
|
|
1653
|
+
"subscription_checkout_ready",
|
|
1523
1654
|
`Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
|
|
1524
1655
|
${res.checkoutUrl}
|
|
1525
1656
|
|
|
1526
1657
|
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
|
|
1658
|
+
Once payment confirms, the site becomes permanent on its current URL. Binding a custom domain (bind_domain) is optional and still requires DNS verification.`,
|
|
1659
|
+
{
|
|
1660
|
+
siteId: res.siteId,
|
|
1661
|
+
plan: res.plan,
|
|
1662
|
+
monthlyPriceJpy: res.monthlyPriceJpy,
|
|
1663
|
+
checkoutUrl: res.checkoutUrl,
|
|
1664
|
+
sessionId: res.sessionId,
|
|
1665
|
+
finalConfirmationProvider: "stripe"
|
|
1666
|
+
},
|
|
1667
|
+
"waiting_user"
|
|
1528
1668
|
);
|
|
1529
1669
|
} catch (e) {
|
|
1530
1670
|
return toolError(e);
|
|
@@ -1535,36 +1675,54 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
1535
1675
|
"bind_domain",
|
|
1536
1676
|
{
|
|
1537
1677
|
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.",
|
|
1678
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1679
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1538
1680
|
inputSchema: {
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
)
|
|
1542
|
-
verificationId: z.string().optional().describe("Check an existing DNS verification instead of starting a new one.")
|
|
1681
|
+
action: z2.enum(["start", "status"]),
|
|
1682
|
+
hostname: z2.string().optional().describe("Required for start."),
|
|
1683
|
+
verificationId: z2.string().optional().describe("Required for status.")
|
|
1543
1684
|
}
|
|
1544
1685
|
},
|
|
1545
1686
|
async (args) => {
|
|
1546
1687
|
try {
|
|
1547
1688
|
const site = requireSiteFile(ctx);
|
|
1548
|
-
if (args.
|
|
1689
|
+
if (args.action === "status") {
|
|
1690
|
+
if (!args.verificationId) {
|
|
1691
|
+
throw new SakupaError("invalid_request", "verificationId is required for status");
|
|
1692
|
+
}
|
|
1549
1693
|
const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
|
|
1550
1694
|
if (res2.status === "verified") {
|
|
1551
1695
|
writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
|
|
1552
1696
|
}
|
|
1553
1697
|
return text(
|
|
1698
|
+
res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
|
|
1554
1699
|
`DNS verification ${res2.verificationId}: ${res2.status}
|
|
1555
1700
|
${res2.message}
|
|
1556
1701
|
` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
|
|
1557
1702
|
` : "") + (res2.pendingDnsRecords.length > 0 ? `
|
|
1558
1703
|
DNS records still required:
|
|
1559
|
-
${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
|
|
1704
|
+
${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
|
|
1705
|
+
{
|
|
1706
|
+
verificationId: res2.verificationId,
|
|
1707
|
+
status: res2.status,
|
|
1708
|
+
apexDomain: res2.apexDomain,
|
|
1709
|
+
provisioningJobId: res2.provisioningJobId,
|
|
1710
|
+
pendingDnsRecords: res2.pendingDnsRecords,
|
|
1711
|
+
message: res2.message
|
|
1712
|
+
},
|
|
1713
|
+
res2.status === "verified" ? "completed" : "pending_provider"
|
|
1560
1714
|
);
|
|
1561
1715
|
}
|
|
1716
|
+
if (!args.hostname) {
|
|
1717
|
+
throw new SakupaError("invalid_request", "hostname is required for start");
|
|
1718
|
+
}
|
|
1562
1719
|
const req = {
|
|
1563
1720
|
siteId: site.siteId,
|
|
1564
1721
|
hostname: args.hostname
|
|
1565
1722
|
};
|
|
1566
1723
|
const res = await ctx.client.bindDomain(site.credential, req);
|
|
1567
1724
|
return text(
|
|
1725
|
+
"domain_verification_started",
|
|
1568
1726
|
`Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
|
|
1569
1727
|
|
|
1570
1728
|
1. Prove control of ${res.apexDomain} by creating this DNS record:
|
|
@@ -1575,7 +1733,15 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
|
|
|
1575
1733
|
|
|
1576
1734
|
2. Serving DNS (after verification): ${res.servingInstructions}
|
|
1577
1735
|
|
|
1578
|
-
Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning
|
|
1736
|
+
Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`,
|
|
1737
|
+
{
|
|
1738
|
+
verificationId: res.verificationId,
|
|
1739
|
+
apexDomain: res.apexDomain,
|
|
1740
|
+
includedHostnames: res.includedHostnames,
|
|
1741
|
+
verificationRecord: res.verificationRecord,
|
|
1742
|
+
servingInstructions: res.servingInstructions
|
|
1743
|
+
},
|
|
1744
|
+
"waiting_user"
|
|
1579
1745
|
);
|
|
1580
1746
|
} catch (e) {
|
|
1581
1747
|
return toolError(e);
|
|
@@ -1586,6 +1752,8 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
|
|
|
1586
1752
|
"billing_status",
|
|
1587
1753
|
{
|
|
1588
1754
|
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).",
|
|
1755
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1756
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
1589
1757
|
inputSchema: {}
|
|
1590
1758
|
},
|
|
1591
1759
|
async () => {
|
|
@@ -1604,7 +1772,7 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
|
|
|
1604
1772
|
res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
|
|
1605
1773
|
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
1774
|
].filter((l) => l !== void 0);
|
|
1607
|
-
return textJson(`${lines.join("\n")}
|
|
1775
|
+
return textJson("billing_status_returned", `${lines.join("\n")}
|
|
1608
1776
|
|
|
1609
1777
|
Full status:`, res);
|
|
1610
1778
|
} catch (e) {
|
|
@@ -1615,41 +1783,53 @@ Full status:`, res);
|
|
|
1615
1783
|
server.registerTool(
|
|
1616
1784
|
"manage_billing",
|
|
1617
1785
|
{
|
|
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.
|
|
1786
|
+
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.",
|
|
1787
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1788
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1619
1789
|
inputSchema: {
|
|
1620
|
-
|
|
1621
|
-
"Without .sakupa/site.json only: the remembered https://{shortId}.sakupa.com URL."
|
|
1622
|
-
)
|
|
1790
|
+
scope: z2.enum(["site", "public_recovery"])
|
|
1623
1791
|
}
|
|
1624
1792
|
},
|
|
1625
1793
|
async (args) => {
|
|
1626
1794
|
try {
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|
-
}
|
|
1795
|
+
if (args.scope === "site") {
|
|
1796
|
+
const site = requireSiteFile(ctx);
|
|
1634
1797
|
const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
|
|
1635
|
-
return
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1798
|
+
return structuredToolResult({
|
|
1799
|
+
schemaVersion: 1,
|
|
1800
|
+
outcome: "waiting_user",
|
|
1801
|
+
resultCode: "site_billing_portal_ready",
|
|
1802
|
+
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`,
|
|
1803
|
+
data: { scope: args.scope, portalUrl: res2.portalUrl },
|
|
1804
|
+
userAction: {
|
|
1805
|
+
type: "open_url",
|
|
1806
|
+
provider: "stripe",
|
|
1807
|
+
url: res2.portalUrl,
|
|
1808
|
+
expectedOutcome: "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u7BA1\u7406\u4ED8\u6B3E\u65B9\u5F0F\u3001\u53D1\u7968\u6216\u53D6\u6D88\u7EED\u8BA2"
|
|
1809
|
+
},
|
|
1810
|
+
nextActions: [{ tool: "billing_status", allowed: true }]
|
|
1811
|
+
});
|
|
1646
1812
|
}
|
|
1647
|
-
const res = await ctx.client.
|
|
1648
|
-
return
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1813
|
+
const res = await ctx.client.getPublicBillingPortal();
|
|
1814
|
+
return structuredToolResult({
|
|
1815
|
+
schemaVersion: 1,
|
|
1816
|
+
outcome: "waiting_user",
|
|
1817
|
+
resultCode: "public_billing_recovery_portal_ready",
|
|
1818
|
+
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`,
|
|
1819
|
+
data: {
|
|
1820
|
+
scope: args.scope,
|
|
1821
|
+
portalUrl: res.portalUrl,
|
|
1822
|
+
grantsSiteAuthority: false,
|
|
1823
|
+
acceptsSiteIdentifier: false
|
|
1824
|
+
},
|
|
1825
|
+
userAction: {
|
|
1826
|
+
type: "open_url",
|
|
1827
|
+
provider: "stripe",
|
|
1828
|
+
url: res.portalUrl,
|
|
1829
|
+
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"
|
|
1830
|
+
},
|
|
1831
|
+
nextActions: []
|
|
1832
|
+
});
|
|
1653
1833
|
} catch (e) {
|
|
1654
1834
|
return toolError(e);
|
|
1655
1835
|
}
|
|
@@ -1659,17 +1839,24 @@ For privacy, this response is identical whether or not the URL, site, customer,
|
|
|
1659
1839
|
"recover_domain_site",
|
|
1660
1840
|
{
|
|
1661
1841
|
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).",
|
|
1842
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1843
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1662
1844
|
inputSchema: {
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1845
|
+
action: z2.enum(["start", "status", "complete"]),
|
|
1846
|
+
hostname: z2.string().optional().describe("Required for start."),
|
|
1847
|
+
verificationId: z2.string().optional().describe("Required for status or complete."),
|
|
1848
|
+
preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
|
|
1666
1849
|
}
|
|
1667
1850
|
},
|
|
1668
1851
|
async (args) => {
|
|
1669
1852
|
try {
|
|
1670
|
-
if (args.
|
|
1853
|
+
if (args.action === "start") {
|
|
1854
|
+
if (!args.hostname) {
|
|
1855
|
+
throw new SakupaError("invalid_request", "hostname is required for start");
|
|
1856
|
+
}
|
|
1671
1857
|
const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
|
|
1672
1858
|
return text(
|
|
1859
|
+
"domain_recovery_started",
|
|
1673
1860
|
`Recovery started for ${args.hostname} (apex domain: ${res2.apexDomain}).
|
|
1674
1861
|
|
|
1675
1862
|
Create this DNS record to prove apex-domain control:
|
|
@@ -1681,9 +1868,40 @@ ${res2.message}
|
|
|
1681
1868
|
|
|
1682
1869
|
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
1870
|
|
|
1684
|
-
After the DNS record resolves, re-run recover_domain_site with verificationId: "${res2.verificationId}"
|
|
1871
|
+
After the DNS record resolves, re-run recover_domain_site with verificationId: "${res2.verificationId}".`,
|
|
1872
|
+
{
|
|
1873
|
+
verificationId: res2.verificationId,
|
|
1874
|
+
apexDomain: res2.apexDomain,
|
|
1875
|
+
verificationRecord: res2.verificationRecord,
|
|
1876
|
+
revokesPreviousCredentialsByDefault: true
|
|
1877
|
+
},
|
|
1878
|
+
"waiting_user"
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
if (!args.verificationId) {
|
|
1882
|
+
throw new SakupaError(
|
|
1883
|
+
"invalid_request",
|
|
1884
|
+
"verificationId is required for status or complete"
|
|
1685
1885
|
);
|
|
1686
1886
|
}
|
|
1887
|
+
if (args.action === "status") {
|
|
1888
|
+
const res2 = await ctx.client.getRecoveryStatus(args.verificationId);
|
|
1889
|
+
return structuredToolResult({
|
|
1890
|
+
schemaVersion: 1,
|
|
1891
|
+
outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
|
|
1892
|
+
resultCode: res2.status === "expired" ? "domain_recovery_expired" : res2.readyToComplete ? "domain_recovery_ready" : "domain_recovery_pending_dns",
|
|
1893
|
+
summary: `DNS \u6062\u590D\u9A8C\u8BC1\u72B6\u6001\uFF1A${res2.status}`,
|
|
1894
|
+
data: { recovery: res2 },
|
|
1895
|
+
nextActions: [
|
|
1896
|
+
{
|
|
1897
|
+
tool: "recover_domain_site",
|
|
1898
|
+
arguments: { action: "complete", verificationId: args.verificationId },
|
|
1899
|
+
allowed: res2.readyToComplete,
|
|
1900
|
+
...res2.readyToComplete ? {} : { reasonCode: res2.status }
|
|
1901
|
+
}
|
|
1902
|
+
]
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1687
1905
|
const res = await ctx.client.completeRecovery(args.verificationId, {
|
|
1688
1906
|
...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
|
|
1689
1907
|
});
|
|
@@ -1695,6 +1913,7 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
|
|
|
1695
1913
|
apiBaseUrl: ctx.apiBaseUrl
|
|
1696
1914
|
});
|
|
1697
1915
|
return text(
|
|
1916
|
+
"domain_recovery_completed",
|
|
1698
1917
|
`Recovery complete.
|
|
1699
1918
|
Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
|
|
1700
1919
|
Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
|
|
@@ -1702,54 +1921,16 @@ Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (pr
|
|
|
1702
1921
|
A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
|
|
1703
1922
|
` + credentialGitReminder(ctx.projectDir) + `
|
|
1704
1923
|
Download the current site content (signed URL):
|
|
1705
|
-
${res.archiveUrl}
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
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
|
-
);
|
|
1924
|
+
${res.archiveUrl}`,
|
|
1925
|
+
{
|
|
1926
|
+
siteId: res.siteId,
|
|
1927
|
+
boundHostnames: res.boundHostnames,
|
|
1928
|
+
revokedPreviousCredentials: res.revokedPreviousCredentials,
|
|
1929
|
+
archiveUrl: res.archiveUrl,
|
|
1930
|
+
archiveExpiresAt: res.archiveExpiresAt,
|
|
1931
|
+
credentialStoredLocally: true
|
|
1750
1932
|
}
|
|
1751
|
-
|
|
1752
|
-
}
|
|
1933
|
+
);
|
|
1753
1934
|
} catch (e) {
|
|
1754
1935
|
return toolError(e);
|
|
1755
1936
|
}
|
|
@@ -1759,11 +1940,13 @@ ${e.message}
|
|
|
1759
1940
|
"create_support_ticket",
|
|
1760
1941
|
{
|
|
1761
1942
|
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.",
|
|
1943
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1944
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1762
1945
|
inputSchema: {
|
|
1763
1946
|
category: ticketCategoryEnum,
|
|
1764
|
-
subject:
|
|
1765
|
-
description:
|
|
1766
|
-
contactEmail:
|
|
1947
|
+
subject: z2.string().describe("Short subject line."),
|
|
1948
|
+
description: z2.string().describe("Problem description (no secrets, no card data)."),
|
|
1949
|
+
contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
|
|
1767
1950
|
}
|
|
1768
1951
|
},
|
|
1769
1952
|
async (args) => {
|
|
@@ -1776,7 +1959,11 @@ ${e.message}
|
|
|
1776
1959
|
description: args.description,
|
|
1777
1960
|
...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
|
|
1778
1961
|
});
|
|
1779
|
-
return text(
|
|
1962
|
+
return text(
|
|
1963
|
+
"support_ticket_created",
|
|
1964
|
+
`Support ticket created: ${res.ticketId} (status: ${res.status}).`,
|
|
1965
|
+
{ ticketId: res.ticketId, status: res.status }
|
|
1966
|
+
);
|
|
1780
1967
|
} catch (e) {
|
|
1781
1968
|
return toolError(e);
|
|
1782
1969
|
}
|
|
@@ -1786,15 +1973,17 @@ ${e.message}
|
|
|
1786
1973
|
"report_bug",
|
|
1787
1974
|
{
|
|
1788
1975
|
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.",
|
|
1976
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1977
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1789
1978
|
inputSchema: {
|
|
1790
|
-
toolName:
|
|
1791
|
-
errorCode:
|
|
1792
|
-
errorMessage:
|
|
1793
|
-
requestId:
|
|
1794
|
-
deploymentId:
|
|
1979
|
+
toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
|
|
1980
|
+
errorCode: z2.string().optional(),
|
|
1981
|
+
errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
|
|
1982
|
+
requestId: z2.string().optional(),
|
|
1983
|
+
deploymentId: z2.string().optional(),
|
|
1795
1984
|
severity: severityEnum.optional(),
|
|
1796
|
-
description:
|
|
1797
|
-
confirmSubmit:
|
|
1985
|
+
description: z2.string().optional().describe("What happened, in the user's words (no secrets)."),
|
|
1986
|
+
confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
|
|
1798
1987
|
}
|
|
1799
1988
|
},
|
|
1800
1989
|
async (args) => {
|
|
@@ -1820,14 +2009,18 @@ ${e.message}
|
|
|
1820
2009
|
};
|
|
1821
2010
|
if (args.confirmSubmit !== true) {
|
|
1822
2011
|
return textJson(
|
|
2012
|
+
"bug_report_preview_ready",
|
|
1823
2013
|
"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
|
|
2014
|
+
payload,
|
|
2015
|
+
"preview"
|
|
1825
2016
|
);
|
|
1826
2017
|
}
|
|
1827
2018
|
const res = await ctx.client.reportBug(payload, site?.credential);
|
|
1828
2019
|
return text(
|
|
2020
|
+
"bug_report_submitted",
|
|
1829
2021
|
`Bug report submitted. Ticket: ${res.ticketId}
|
|
1830
|
-
Summary: ${res.sanitizedSummary}
|
|
2022
|
+
Summary: ${res.sanitizedSummary}`,
|
|
2023
|
+
{ ticketId: res.ticketId, sanitizedSummary: res.sanitizedSummary }
|
|
1831
2024
|
);
|
|
1832
2025
|
} catch (e) {
|
|
1833
2026
|
return toolError(e);
|
|
@@ -1836,8 +2029,215 @@ Summary: ${res.sanitizedSummary}`
|
|
|
1836
2029
|
);
|
|
1837
2030
|
}
|
|
1838
2031
|
|
|
2032
|
+
// src/tools/lifecycle.ts
|
|
2033
|
+
import { z as z3 } from "zod";
|
|
2034
|
+
var deleteConfirmation = z3.object({
|
|
2035
|
+
siteId: z3.string().min(1),
|
|
2036
|
+
expectedSiteUpdatedAt: z3.string().datetime(),
|
|
2037
|
+
expectedStatus: z3.enum(["active", "expired", "deleted"]),
|
|
2038
|
+
expectedMode: z3.enum(["free", "paid"]),
|
|
2039
|
+
expectedServingMode: z3.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
|
|
2040
|
+
expectedShortId: z3.string().optional(),
|
|
2041
|
+
expectedSubscriptionStatus: z3.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
|
|
2042
|
+
expectedPlan: z3.enum(["water", "personal", "share", "business"]).optional(),
|
|
2043
|
+
expectedCancelAtPeriodEnd: z3.boolean().optional(),
|
|
2044
|
+
expectedCurrentPeriodEnd: z3.string().datetime().optional(),
|
|
2045
|
+
expectedLastDeploymentId: z3.string().optional(),
|
|
2046
|
+
expectedBoundHostnames: z3.array(z3.string()),
|
|
2047
|
+
acknowledge: z3.literal("delete_site_and_cancel_renewal")
|
|
2048
|
+
});
|
|
2049
|
+
var unbindConfirmation = z3.object({
|
|
2050
|
+
siteId: z3.string().min(1),
|
|
2051
|
+
bindingId: z3.string().min(1),
|
|
2052
|
+
expectedBindingUpdatedAt: z3.string().datetime(),
|
|
2053
|
+
expectedBindingStatus: z3.enum(["provisioning", "active"]),
|
|
2054
|
+
apexDomain: z3.string().min(1),
|
|
2055
|
+
expectedBoundHostnames: z3.array(z3.string()),
|
|
2056
|
+
acknowledge: z3.literal("unbind_domain_and_remove_custom_hostnames")
|
|
2057
|
+
});
|
|
2058
|
+
function registerLifecycleTools(server, ctx) {
|
|
2059
|
+
server.registerTool(
|
|
2060
|
+
"delete_site",
|
|
2061
|
+
{
|
|
2062
|
+
description: "Preview or execute deletion of this Sakupa site. Execution requires an exact server-validated confirmation bound to the current site state.",
|
|
2063
|
+
inputSchema: {
|
|
2064
|
+
action: z3.enum(["preview", "confirm"]),
|
|
2065
|
+
operationId: z3.string().min(1).optional(),
|
|
2066
|
+
confirmation: deleteConfirmation.optional()
|
|
2067
|
+
},
|
|
2068
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2069
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
|
|
2070
|
+
},
|
|
2071
|
+
async (args) => {
|
|
2072
|
+
try {
|
|
2073
|
+
const site = requireSiteFile(ctx);
|
|
2074
|
+
if (!args.operationId) {
|
|
2075
|
+
throw new Error("operationId is required for delete_site");
|
|
2076
|
+
}
|
|
2077
|
+
if (args.action === "preview") {
|
|
2078
|
+
const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
|
|
2079
|
+
operationId: args.operationId
|
|
2080
|
+
});
|
|
2081
|
+
return structuredToolResult({
|
|
2082
|
+
schemaVersion: 1,
|
|
2083
|
+
outcome: "waiting_user",
|
|
2084
|
+
resultCode: "delete_site_confirmation_required",
|
|
2085
|
+
operationId: args.operationId,
|
|
2086
|
+
summary: "\u5DF2\u8FD4\u56DE\u4E0E\u5F53\u524D\u7AD9\u70B9\u53CA\u8D26\u5355\u72B6\u6001\u7ED1\u5B9A\u7684\u5220\u9664\u540E\u679C\uFF1B\u786E\u8BA4\u540E\u5185\u5BB9\u548C\u6C38\u4E45\u5730\u5740\u4E0D\u53EF\u6062\u590D\u3002",
|
|
2087
|
+
data: { preview },
|
|
2088
|
+
nextActions: [
|
|
2089
|
+
{ tool: "delete_site", allowed: true, reasonCode: "exact_confirmation_required" }
|
|
2090
|
+
]
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
if (!args.confirmation) throw new Error("confirmation is required for confirm");
|
|
2094
|
+
const result = await ctx.client.deleteSite(site.siteId, site.credential, {
|
|
2095
|
+
operationId: args.operationId,
|
|
2096
|
+
confirmation: args.confirmation
|
|
2097
|
+
});
|
|
2098
|
+
deleteSiteFile(ctx.projectDir);
|
|
2099
|
+
return structuredToolResult({
|
|
2100
|
+
schemaVersion: 1,
|
|
2101
|
+
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2102
|
+
resultCode: "site_deleted",
|
|
2103
|
+
operationId: args.operationId,
|
|
2104
|
+
summary: "\u7AD9\u70B9\u5DF2\u5220\u9664\uFF0C\u672C\u5730\u7BA1\u7406\u51ED\u8BC1\u6587\u4EF6\u5DF2\u79FB\u9664\u3002",
|
|
2105
|
+
data: { result },
|
|
2106
|
+
nextActions: []
|
|
2107
|
+
});
|
|
2108
|
+
} catch (error) {
|
|
2109
|
+
return toolError(error);
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
);
|
|
2113
|
+
server.registerTool(
|
|
2114
|
+
"unbind_domain",
|
|
2115
|
+
{
|
|
2116
|
+
description: "Preview or execute removal of the custom apex/www serving surface while preserving the subscription and permanent Sakupa URL.",
|
|
2117
|
+
inputSchema: {
|
|
2118
|
+
action: z3.enum(["preview", "confirm"]),
|
|
2119
|
+
operationId: z3.string().min(1).optional(),
|
|
2120
|
+
confirmation: unbindConfirmation.optional()
|
|
2121
|
+
},
|
|
2122
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2123
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
|
|
2124
|
+
},
|
|
2125
|
+
async (args) => {
|
|
2126
|
+
try {
|
|
2127
|
+
const site = requireSiteFile(ctx);
|
|
2128
|
+
if (!args.operationId) throw new Error("operationId is required for unbind_domain");
|
|
2129
|
+
if (args.action === "preview") {
|
|
2130
|
+
const preview = await ctx.client.previewUnbindDomain(site.siteId, site.credential, {
|
|
2131
|
+
operationId: args.operationId
|
|
2132
|
+
});
|
|
2133
|
+
return structuredToolResult({
|
|
2134
|
+
schemaVersion: 1,
|
|
2135
|
+
outcome: "waiting_user",
|
|
2136
|
+
resultCode: "unbind_domain_confirmation_required",
|
|
2137
|
+
operationId: args.operationId,
|
|
2138
|
+
summary: "\u5DF2\u8FD4\u56DE\u7CBE\u786E\u7ED1\u5B9A\u5FEB\u7167\uFF1B\u89E3\u7ED1\u53EA\u79FB\u9664\u81EA\u5B9A\u4E49\u57DF\u540D\uFF0C\u8BA2\u9605\u3001\u5185\u5BB9\u548C\u6C38\u4E45\u5730\u5740\u4FDD\u6301\u4E0D\u53D8\u3002",
|
|
2139
|
+
data: { preview },
|
|
2140
|
+
nextActions: [
|
|
2141
|
+
{ tool: "unbind_domain", allowed: true, reasonCode: "exact_confirmation_required" }
|
|
2142
|
+
]
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
if (!args.confirmation) throw new Error("confirmation is required for confirm");
|
|
2146
|
+
const result = await ctx.client.unbindDomain(site.siteId, site.credential, {
|
|
2147
|
+
operationId: args.operationId,
|
|
2148
|
+
confirmation: args.confirmation
|
|
2149
|
+
});
|
|
2150
|
+
const { boundDomain: _removed, ...remaining } = site;
|
|
2151
|
+
writeSiteFile(ctx.projectDir, remaining);
|
|
2152
|
+
return structuredToolResult({
|
|
2153
|
+
schemaVersion: 1,
|
|
2154
|
+
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2155
|
+
resultCode: "domain_unbound",
|
|
2156
|
+
operationId: args.operationId,
|
|
2157
|
+
summary: "\u81EA\u5B9A\u4E49\u57DF\u540D\u5DF2\u89E3\u7ED1\uFF1B\u8BA2\u9605\u3001\u5DF2\u90E8\u7F72\u5185\u5BB9\u548C\u6C38\u4E45 Sakupa \u5730\u5740\u672A\u53D8\u3002",
|
|
2158
|
+
data: { result },
|
|
2159
|
+
nextActions: [{ tool: "site_status", allowed: true }]
|
|
2160
|
+
});
|
|
2161
|
+
} catch (error) {
|
|
2162
|
+
return toolError(error);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
1839
2168
|
// src/server.ts
|
|
1840
2169
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2170
|
+
|
|
2171
|
+
// src/tools/billing.ts
|
|
2172
|
+
import { z as z4 } from "zod";
|
|
2173
|
+
var plan = z4.enum(["water", "personal", "share", "business"]);
|
|
2174
|
+
function registerBillingTools(server, ctx) {
|
|
2175
|
+
server.registerTool(
|
|
2176
|
+
"list_billing_plans",
|
|
2177
|
+
{
|
|
2178
|
+
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.",
|
|
2179
|
+
inputSchema: {},
|
|
2180
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2181
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
2182
|
+
},
|
|
2183
|
+
async () => {
|
|
2184
|
+
try {
|
|
2185
|
+
const catalog = await ctx.client.getBillingPlanCatalog();
|
|
2186
|
+
return structuredToolResult({
|
|
2187
|
+
schemaVersion: 1,
|
|
2188
|
+
outcome: "completed",
|
|
2189
|
+
resultCode: "billing_catalog_returned",
|
|
2190
|
+
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`,
|
|
2191
|
+
data: { catalog },
|
|
2192
|
+
nextActions: [{ tool: "subscribe_site", allowed: true }]
|
|
2193
|
+
});
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
return toolError(error);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
);
|
|
2199
|
+
server.registerTool(
|
|
2200
|
+
"change_subscription_plan",
|
|
2201
|
+
{
|
|
2202
|
+
description: "Create a Stripe-hosted confirmation link for a manually selected subscription plan. Creating the link does not change billing; only the user can confirm on Stripe.",
|
|
2203
|
+
inputSchema: {
|
|
2204
|
+
targetPlan: plan,
|
|
2205
|
+
operationId: z4.string().min(1)
|
|
2206
|
+
},
|
|
2207
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2208
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
2209
|
+
},
|
|
2210
|
+
async (args) => {
|
|
2211
|
+
try {
|
|
2212
|
+
const site = requireSiteFile(ctx);
|
|
2213
|
+
const result = await ctx.client.changeSubscriptionPlan(site.credential, {
|
|
2214
|
+
siteId: site.siteId,
|
|
2215
|
+
targetPlan: args.targetPlan,
|
|
2216
|
+
operationId: args.operationId
|
|
2217
|
+
});
|
|
2218
|
+
return structuredToolResult({
|
|
2219
|
+
schemaVersion: 1,
|
|
2220
|
+
outcome: "waiting_user",
|
|
2221
|
+
resultCode: "stripe_plan_change_confirmation_required",
|
|
2222
|
+
operationId: args.operationId,
|
|
2223
|
+
summary: `\u5DF2\u751F\u6210\u4ECE ${result.currentPlan} \u5230 ${result.targetPlan} \u7684 Stripe \u786E\u8BA4\u94FE\u63A5\uFF1B\u8BA2\u9605\u5C1A\u672A\u53D8\u66F4\u3002`,
|
|
2224
|
+
data: { result },
|
|
2225
|
+
userAction: {
|
|
2226
|
+
type: "open_url",
|
|
2227
|
+
provider: "stripe",
|
|
2228
|
+
url: result.portalUrl,
|
|
2229
|
+
expectedOutcome: "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u786E\u8BA4\u540E\uFF0C\u7531 webhook \u66F4\u65B0 Sakupa \u8BA2\u9605\u72B6\u6001"
|
|
2230
|
+
},
|
|
2231
|
+
nextActions: [{ tool: "billing_status", allowed: true }]
|
|
2232
|
+
});
|
|
2233
|
+
} catch (error) {
|
|
2234
|
+
return toolError(error);
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
// src/server.ts
|
|
1841
2241
|
var INSTRUCTIONS = `Sakupa publishes AI-made static websites. AI-made pages, live in seconds.
|
|
1842
2242
|
|
|
1843
2243
|
Workflow:
|
|
@@ -1849,14 +2249,17 @@ Workflow:
|
|
|
1849
2249
|
management credential in .sakupa/site.json. Deploying again updates the site and refreshes
|
|
1850
2250
|
its validity; refresh_site extends validity without uploading.
|
|
1851
2251
|
3. To make the site PERMANENT, subscribe it to a monthly hosting plan (subscribe_site ->
|
|
1852
|
-
Stripe-hosted checkout; water/personal/share/business
|
|
1853
|
-
|
|
2252
|
+
Stripe-hosted checkout; water/personal/share/business). Paying makes the
|
|
2253
|
+
{shortId}.sakupa.com URL permanent \u2014 that is what payment buys. Usage over the chosen plan
|
|
2254
|
+
shows an over-limit notice by default. An external AI may periodically query usage and
|
|
2255
|
+
recommend a plan, but Sakupa never changes a subscription automatically.
|
|
1854
2256
|
4. Optionally bind a custom domain to the subscribed site (bind_domain): an included extra
|
|
1855
2257
|
serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
|
|
1856
2258
|
first verified request wins; unverified requests expire after 72 hours. billing_status,
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
2259
|
+
billing_status, change_subscription_plan, manage_billing and recover_domain_site manage the paid
|
|
2260
|
+
lifecycle. Plan changes are confirmed only on Stripe and synchronized by Stripe webhook.
|
|
2261
|
+
A cancellation keeps the site paid through the current period. Sakupa reverts it to a free
|
|
2262
|
+
24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
|
|
1860
2263
|
|
|
1861
2264
|
Safety boundaries:
|
|
1862
2265
|
- Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
|
|
@@ -1864,9 +2267,9 @@ Safety boundaries:
|
|
|
1864
2267
|
- Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
|
|
1865
2268
|
- A subscription never grants domain ownership; only DNS verification does.
|
|
1866
2269
|
- 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
|
|
1868
|
-
|
|
1869
|
-
|
|
2270
|
+
a bound custom domain, a lost credential is unrecoverable by design. manage_billing then opens
|
|
2271
|
+
Stripe's public no-code portal login, where the customer verifies the checkout email with a
|
|
2272
|
+
Stripe one-time passcode; it never restores site authority.`;
|
|
1870
2273
|
function createSakupaMcpServer(opts) {
|
|
1871
2274
|
const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
|
|
1872
2275
|
const server = new McpServer(
|
|
@@ -1878,6 +2281,16 @@ function createSakupaMcpServer(opts) {
|
|
|
1878
2281
|
projectDir: opts.projectDir,
|
|
1879
2282
|
apiBaseUrl: opts.apiBaseUrl
|
|
1880
2283
|
});
|
|
2284
|
+
registerBillingTools(server, {
|
|
2285
|
+
client,
|
|
2286
|
+
projectDir: opts.projectDir,
|
|
2287
|
+
apiBaseUrl: opts.apiBaseUrl
|
|
2288
|
+
});
|
|
2289
|
+
registerLifecycleTools(server, {
|
|
2290
|
+
client,
|
|
2291
|
+
projectDir: opts.projectDir,
|
|
2292
|
+
apiBaseUrl: opts.apiBaseUrl
|
|
2293
|
+
});
|
|
1881
2294
|
return server;
|
|
1882
2295
|
}
|
|
1883
2296
|
export {
|
|
@@ -1889,6 +2302,7 @@ export {
|
|
|
1889
2302
|
createSakupaMcpServer,
|
|
1890
2303
|
deleteSiteFile,
|
|
1891
2304
|
readSiteFile,
|
|
2305
|
+
registerLifecycleTools,
|
|
1892
2306
|
registerTools,
|
|
1893
2307
|
requireSiteFile,
|
|
1894
2308
|
siteFilePath,
|