@sakupa/mcp 0.4.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.
- package/dist/bin.js +492 -146
- package/dist/index.js +497 -149
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -126,6 +126,9 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
126
126
|
];
|
|
127
127
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
128
128
|
|
|
129
|
+
// ../core/dist/domain/version.js
|
|
130
|
+
var SAKUPA_MCP_VERSION = "0.6.0";
|
|
131
|
+
|
|
129
132
|
// ../core/dist/domain/errors.js
|
|
130
133
|
var HTTP_STATUS = {
|
|
131
134
|
invalid_request: 400,
|
|
@@ -449,8 +452,17 @@ async function sha256Hex(bytes) {
|
|
|
449
452
|
return hex;
|
|
450
453
|
}
|
|
451
454
|
|
|
452
|
-
// ../core/dist/domain/
|
|
453
|
-
var
|
|
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
|
+
};
|
|
454
466
|
|
|
455
467
|
// ../core/dist/dto.js
|
|
456
468
|
var CREDENTIAL_HEADER = "x-sakupa-credential";
|
|
@@ -461,6 +473,12 @@ var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
|
|
|
461
473
|
var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
|
462
474
|
var utf8Encoder = new TextEncoder();
|
|
463
475
|
|
|
476
|
+
// ../core/dist/services/authorization.js
|
|
477
|
+
var AUTHORIZATION_TTL_MS = 15 * 60 * 1e3;
|
|
478
|
+
|
|
479
|
+
// ../core/dist/services/subscriptions.js
|
|
480
|
+
var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
|
|
481
|
+
|
|
464
482
|
// src/server.ts
|
|
465
483
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
466
484
|
|
|
@@ -572,6 +590,12 @@ var HttpApiClient = class {
|
|
|
572
590
|
async recoverDomain(req) {
|
|
573
591
|
return this.call("POST", "/v1/domains/recover", { body: req });
|
|
574
592
|
}
|
|
593
|
+
async getRecoveryStatus(verificationId) {
|
|
594
|
+
return this.call(
|
|
595
|
+
"GET",
|
|
596
|
+
`/v1/domains/recover/${encodeURIComponent(verificationId)}`
|
|
597
|
+
);
|
|
598
|
+
}
|
|
575
599
|
async completeRecovery(verificationId, req) {
|
|
576
600
|
return this.call(
|
|
577
601
|
"POST",
|
|
@@ -602,13 +626,19 @@ var HttpApiClient = class {
|
|
|
602
626
|
{ credential }
|
|
603
627
|
);
|
|
604
628
|
}
|
|
605
|
-
async
|
|
629
|
+
async getBillingPlanCatalog() {
|
|
630
|
+
return this.call("GET", "/v1/billing/plans");
|
|
631
|
+
}
|
|
632
|
+
async manageSubscription(siteId, credential, req) {
|
|
606
633
|
return this.call(
|
|
607
634
|
"POST",
|
|
608
|
-
`/v1/sites/${encodeURIComponent(siteId)}/billing/
|
|
635
|
+
`/v1/sites/${encodeURIComponent(siteId)}/billing/manage`,
|
|
609
636
|
{ credential, body: req }
|
|
610
637
|
);
|
|
611
638
|
}
|
|
639
|
+
async getPublicBillingPortal() {
|
|
640
|
+
return this.call("GET", "/v1/billing/portal");
|
|
641
|
+
}
|
|
612
642
|
async createTicket(credential, req) {
|
|
613
643
|
return this.call("POST", "/v1/support/tickets", {
|
|
614
644
|
credential,
|
|
@@ -627,7 +657,7 @@ var HttpApiClient = class {
|
|
|
627
657
|
import { randomUUID } from "node:crypto";
|
|
628
658
|
import { promises as fs2 } from "node:fs";
|
|
629
659
|
import { join as join3, resolve as resolve2 } from "node:path";
|
|
630
|
-
import { z } from "zod";
|
|
660
|
+
import { z as z2 } from "zod";
|
|
631
661
|
|
|
632
662
|
// src/analyze/analyzer.ts
|
|
633
663
|
import { promises as fs } from "node:fs";
|
|
@@ -1115,9 +1145,50 @@ function credentialGitReminder(projectDir) {
|
|
|
1115
1145
|
}
|
|
1116
1146
|
|
|
1117
1147
|
// src/version.ts
|
|
1118
|
-
var MCP_VERSION =
|
|
1148
|
+
var MCP_VERSION = SAKUPA_MCP_VERSION;
|
|
1119
1149
|
var CLIENT_TYPE = "sakupa-mcp";
|
|
1120
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
|
+
|
|
1121
1192
|
// src/tools/context.ts
|
|
1122
1193
|
function requireSiteFile(ctx) {
|
|
1123
1194
|
const file = readSiteFile(ctx.projectDir);
|
|
@@ -1130,35 +1201,49 @@ function requireSiteFile(ctx) {
|
|
|
1130
1201
|
return file;
|
|
1131
1202
|
}
|
|
1132
1203
|
function toolError(e) {
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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 };
|
|
1143
1216
|
}
|
|
1144
1217
|
|
|
1145
1218
|
// src/tools/definitions.ts
|
|
1146
|
-
function text(t) {
|
|
1147
|
-
return {
|
|
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
|
+
});
|
|
1148
1228
|
}
|
|
1149
|
-
function textJson(header, obj) {
|
|
1150
|
-
|
|
1151
|
-
${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
|
+
});
|
|
1152
1240
|
}
|
|
1153
|
-
var planEnum =
|
|
1154
|
-
var severityEnum =
|
|
1241
|
+
var planEnum = z2.enum(["water", "personal", "share", "business"]);
|
|
1242
|
+
var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
|
|
1155
1243
|
function planCatalog() {
|
|
1156
1244
|
return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
|
|
1157
1245
|
}
|
|
1158
|
-
|
|
1159
|
-
return SUBSCRIPTION_WARNING_TEXT.replaceAll("{siteUrl}", siteUrl).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
|
|
1160
|
-
}
|
|
1161
|
-
var ticketCategoryEnum = z.enum([
|
|
1246
|
+
var ticketCategoryEnum = z2.enum([
|
|
1162
1247
|
"billing",
|
|
1163
1248
|
"payment",
|
|
1164
1249
|
"refund_review",
|
|
@@ -1176,14 +1261,17 @@ function analysisSummary(analysis) {
|
|
|
1176
1261
|
}
|
|
1177
1262
|
function notDeployableResult(analysis) {
|
|
1178
1263
|
return textJson(
|
|
1264
|
+
"site_analysis_not_deployable",
|
|
1179
1265
|
`This project is NOT deployable as-is. No files were uploaded and no API call was made.
|
|
1180
1266
|
Next action: ${analysis.suggestedNextAction}
|
|
1181
1267
|
Analysis:`,
|
|
1182
|
-
analysisSummary(analysis)
|
|
1268
|
+
analysisSummary(analysis),
|
|
1269
|
+
"blocked"
|
|
1183
1270
|
);
|
|
1184
1271
|
}
|
|
1185
1272
|
function spaConfirmationResult(analysis) {
|
|
1186
1273
|
return text(
|
|
1274
|
+
"spa_fallback_confirmation_required",
|
|
1187
1275
|
`SPA fallback confirmation required \u2014 nothing was deployed yet.
|
|
1188
1276
|
|
|
1189
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.
|
|
@@ -1192,7 +1280,9 @@ Please ask the user to choose, then re-run deploy_site with:
|
|
|
1192
1280
|
- spaFallback: true, spaFallbackConfirmed: true -> enable SPA fallback
|
|
1193
1281
|
- spaFallbackConfirmed: true (spaFallback omitted or false) -> deploy WITHOUT fallback (unknown paths return 404)
|
|
1194
1282
|
|
|
1195
|
-
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"
|
|
1196
1286
|
);
|
|
1197
1287
|
}
|
|
1198
1288
|
var MB2 = 1024 * 1024;
|
|
@@ -1252,10 +1342,12 @@ function registerTools(server, ctx) {
|
|
|
1252
1342
|
"analyze_site",
|
|
1253
1343
|
{
|
|
1254
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 },
|
|
1255
1347
|
inputSchema: {
|
|
1256
|
-
outputDir:
|
|
1257
|
-
spaFallbackRequested:
|
|
1258
|
-
spaFallbackConfirmed:
|
|
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.")
|
|
1259
1351
|
}
|
|
1260
1352
|
},
|
|
1261
1353
|
async (args) => {
|
|
@@ -1266,6 +1358,7 @@ function registerTools(server, ctx) {
|
|
|
1266
1358
|
...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
|
|
1267
1359
|
});
|
|
1268
1360
|
return textJson(
|
|
1361
|
+
"site_analysis_completed",
|
|
1269
1362
|
`Analysis of ${ctx.projectDir}
|
|
1270
1363
|
Next action: ${analysis.suggestedNextAction}`,
|
|
1271
1364
|
analysisSummary(analysis)
|
|
@@ -1279,11 +1372,16 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1279
1372
|
"deploy_site",
|
|
1280
1373
|
{
|
|
1281
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 },
|
|
1282
1377
|
inputSchema: {
|
|
1283
|
-
outputDir:
|
|
1284
|
-
spaFallback:
|
|
1285
|
-
spaFallbackConfirmed:
|
|
1286
|
-
|
|
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(
|
|
1382
|
+
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
1383
|
+
),
|
|
1384
|
+
lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
1287
1385
|
}
|
|
1288
1386
|
},
|
|
1289
1387
|
async (args) => {
|
|
@@ -1303,6 +1401,14 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1303
1401
|
const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
|
|
1304
1402
|
const manifest = await buildHashedManifest(files, outputAbs);
|
|
1305
1403
|
const existing = readSiteFile(ctx.projectDir);
|
|
1404
|
+
if (!existing && args.publicConfirmed !== true) {
|
|
1405
|
+
return text(
|
|
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"
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1306
1412
|
ensureUploadSizeWithinLimits(manifest, !existing);
|
|
1307
1413
|
if (!existing) {
|
|
1308
1414
|
const created = await ctx.client.createSite({
|
|
@@ -1326,6 +1432,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1326
1432
|
apiBaseUrl: ctx.apiBaseUrl
|
|
1327
1433
|
});
|
|
1328
1434
|
return text(
|
|
1435
|
+
"site_published",
|
|
1329
1436
|
`Site published: ${finalized2.url}
|
|
1330
1437
|
Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
1331
1438
|
` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
|
|
@@ -1333,7 +1440,19 @@ Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
|
1333
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.
|
|
1334
1441
|
` + credentialGitReminder(ctx.projectDir) + (finalized2.warnings.length > 0 ? `
|
|
1335
1442
|
Warnings:
|
|
1336
|
-
${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
|
+
}
|
|
1337
1456
|
);
|
|
1338
1457
|
}
|
|
1339
1458
|
const updateOnce = async (forceFullUpload) => {
|
|
@@ -1367,6 +1486,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
|
|
|
1367
1486
|
const { uploaded, finalized } = update;
|
|
1368
1487
|
writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
|
|
1369
1488
|
return text(
|
|
1489
|
+
"site_updated",
|
|
1370
1490
|
`Site updated: ${finalized.url}
|
|
1371
1491
|
Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
1372
1492
|
` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
|
|
@@ -1374,7 +1494,16 @@ Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
|
1374
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.
|
|
1375
1495
|
` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
|
|
1376
1496
|
Warnings:
|
|
1377
|
-
${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
|
+
}
|
|
1378
1507
|
);
|
|
1379
1508
|
} catch (e) {
|
|
1380
1509
|
return toolError(e);
|
|
@@ -1385,6 +1514,8 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
|
|
|
1385
1514
|
"refresh_site",
|
|
1386
1515
|
{
|
|
1387
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 },
|
|
1388
1519
|
inputSchema: {}
|
|
1389
1520
|
},
|
|
1390
1521
|
async () => {
|
|
@@ -1392,8 +1523,10 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
|
|
|
1392
1523
|
const site = requireSiteFile(ctx);
|
|
1393
1524
|
const res = await ctx.client.refreshSite(site.siteId, site.credential);
|
|
1394
1525
|
return text(
|
|
1526
|
+
"site_refreshed",
|
|
1395
1527
|
`Site validity refreshed. New expiry: ${res.expiresAt}
|
|
1396
|
-
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 }
|
|
1397
1530
|
);
|
|
1398
1531
|
} catch (e) {
|
|
1399
1532
|
return toolError(e);
|
|
@@ -1404,13 +1537,15 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
|
|
|
1404
1537
|
"site_status",
|
|
1405
1538
|
{
|
|
1406
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 },
|
|
1407
1542
|
inputSchema: {}
|
|
1408
1543
|
},
|
|
1409
1544
|
async () => {
|
|
1410
1545
|
try {
|
|
1411
1546
|
const site = requireSiteFile(ctx);
|
|
1412
1547
|
const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
|
|
1413
|
-
return textJson("Site status:", res);
|
|
1548
|
+
return textJson("site_status_returned", "Site status:", res);
|
|
1414
1549
|
} catch (e) {
|
|
1415
1550
|
return toolError(e);
|
|
1416
1551
|
}
|
|
@@ -1419,40 +1554,42 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
|
|
|
1419
1554
|
server.registerTool(
|
|
1420
1555
|
"subscribe_site",
|
|
1421
1556
|
{
|
|
1422
|
-
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
|
|
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 },
|
|
1423
1560
|
inputSchema: {
|
|
1424
1561
|
plan: planEnum.describe(
|
|
1425
1562
|
"Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
|
|
1426
|
-
)
|
|
1427
|
-
confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
|
|
1563
|
+
)
|
|
1428
1564
|
}
|
|
1429
1565
|
},
|
|
1430
1566
|
async (args) => {
|
|
1431
1567
|
try {
|
|
1432
1568
|
const site = requireSiteFile(ctx);
|
|
1433
|
-
const siteUrl = site.url ?? (site.shortId ? `https://${site.shortId}.sakupa.com` : site.siteId);
|
|
1434
|
-
if (args.confirm !== true) {
|
|
1435
|
-
return text(
|
|
1436
|
-
`${subscriptionWarning(siteUrl, args.plan)}
|
|
1437
|
-
|
|
1438
|
-
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.`
|
|
1439
|
-
);
|
|
1440
|
-
}
|
|
1441
1569
|
const res = await ctx.client.createPlanCheckout(
|
|
1442
1570
|
{
|
|
1443
1571
|
siteId: site.siteId,
|
|
1444
1572
|
plan: args.plan,
|
|
1445
|
-
idempotencyKey: randomUUID()
|
|
1446
|
-
confirmPlan: true
|
|
1573
|
+
idempotencyKey: randomUUID()
|
|
1447
1574
|
},
|
|
1448
1575
|
site.credential
|
|
1449
1576
|
);
|
|
1450
1577
|
return text(
|
|
1578
|
+
"subscription_checkout_ready",
|
|
1451
1579
|
`Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
|
|
1452
1580
|
${res.checkoutUrl}
|
|
1453
1581
|
|
|
1454
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.
|
|
1455
|
-
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"
|
|
1456
1593
|
);
|
|
1457
1594
|
} catch (e) {
|
|
1458
1595
|
return toolError(e);
|
|
@@ -1463,36 +1600,54 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
1463
1600
|
"bind_domain",
|
|
1464
1601
|
{
|
|
1465
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 },
|
|
1466
1605
|
inputSchema: {
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
)
|
|
1470
|
-
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.")
|
|
1471
1609
|
}
|
|
1472
1610
|
},
|
|
1473
1611
|
async (args) => {
|
|
1474
1612
|
try {
|
|
1475
1613
|
const site = requireSiteFile(ctx);
|
|
1476
|
-
if (args.
|
|
1614
|
+
if (args.action === "status") {
|
|
1615
|
+
if (!args.verificationId) {
|
|
1616
|
+
throw new SakupaError("invalid_request", "verificationId is required for status");
|
|
1617
|
+
}
|
|
1477
1618
|
const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
|
|
1478
1619
|
if (res2.status === "verified") {
|
|
1479
1620
|
writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
|
|
1480
1621
|
}
|
|
1481
1622
|
return text(
|
|
1623
|
+
res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
|
|
1482
1624
|
`DNS verification ${res2.verificationId}: ${res2.status}
|
|
1483
1625
|
${res2.message}
|
|
1484
1626
|
` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
|
|
1485
1627
|
` : "") + (res2.pendingDnsRecords.length > 0 ? `
|
|
1486
1628
|
DNS records still required:
|
|
1487
|
-
${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"
|
|
1488
1639
|
);
|
|
1489
1640
|
}
|
|
1641
|
+
if (!args.hostname) {
|
|
1642
|
+
throw new SakupaError("invalid_request", "hostname is required for start");
|
|
1643
|
+
}
|
|
1490
1644
|
const req = {
|
|
1491
1645
|
siteId: site.siteId,
|
|
1492
1646
|
hostname: args.hostname
|
|
1493
1647
|
};
|
|
1494
1648
|
const res = await ctx.client.bindDomain(site.credential, req);
|
|
1495
1649
|
return text(
|
|
1650
|
+
"domain_verification_started",
|
|
1496
1651
|
`Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
|
|
1497
1652
|
|
|
1498
1653
|
1. Prove control of ${res.apexDomain} by creating this DNS record:
|
|
@@ -1503,7 +1658,15 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
|
|
|
1503
1658
|
|
|
1504
1659
|
2. Serving DNS (after verification): ${res.servingInstructions}
|
|
1505
1660
|
|
|
1506
|
-
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"
|
|
1507
1670
|
);
|
|
1508
1671
|
} catch (e) {
|
|
1509
1672
|
return toolError(e);
|
|
@@ -1514,6 +1677,8 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
|
|
|
1514
1677
|
"billing_status",
|
|
1515
1678
|
{
|
|
1516
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 },
|
|
1517
1682
|
inputSchema: {}
|
|
1518
1683
|
},
|
|
1519
1684
|
async () => {
|
|
@@ -1532,7 +1697,7 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
|
|
|
1532
1697
|
res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
|
|
1533
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
|
|
1534
1699
|
].filter((l) => l !== void 0);
|
|
1535
|
-
return textJson(`${lines.join("\n")}
|
|
1700
|
+
return textJson("billing_status_returned", `${lines.join("\n")}
|
|
1536
1701
|
|
|
1537
1702
|
Full status:`, res);
|
|
1538
1703
|
} catch (e) {
|
|
@@ -1543,19 +1708,53 @@ Full status:`, res);
|
|
|
1543
1708
|
server.registerTool(
|
|
1544
1709
|
"manage_billing",
|
|
1545
1710
|
{
|
|
1546
|
-
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.
|
|
1547
|
-
|
|
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 },
|
|
1714
|
+
inputSchema: {
|
|
1715
|
+
scope: z2.enum(["site", "public_recovery"])
|
|
1716
|
+
}
|
|
1548
1717
|
},
|
|
1549
|
-
async () => {
|
|
1718
|
+
async (args) => {
|
|
1550
1719
|
try {
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1720
|
+
if (args.scope === "site") {
|
|
1721
|
+
const site = requireSiteFile(ctx);
|
|
1722
|
+
const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
|
|
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
|
+
});
|
|
1737
|
+
}
|
|
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
|
+
});
|
|
1559
1758
|
} catch (e) {
|
|
1560
1759
|
return toolError(e);
|
|
1561
1760
|
}
|
|
@@ -1565,17 +1764,24 @@ Open this link in a browser to update the payment method, view invoices, or mana
|
|
|
1565
1764
|
"recover_domain_site",
|
|
1566
1765
|
{
|
|
1567
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 },
|
|
1568
1769
|
inputSchema: {
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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).")
|
|
1572
1774
|
}
|
|
1573
1775
|
},
|
|
1574
1776
|
async (args) => {
|
|
1575
1777
|
try {
|
|
1576
|
-
if (args.
|
|
1778
|
+
if (args.action === "start") {
|
|
1779
|
+
if (!args.hostname) {
|
|
1780
|
+
throw new SakupaError("invalid_request", "hostname is required for start");
|
|
1781
|
+
}
|
|
1577
1782
|
const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
|
|
1578
1783
|
return text(
|
|
1784
|
+
"domain_recovery_started",
|
|
1579
1785
|
`Recovery started for ${args.hostname} (apex domain: ${res2.apexDomain}).
|
|
1580
1786
|
|
|
1581
1787
|
Create this DNS record to prove apex-domain control:
|
|
@@ -1587,9 +1793,40 @@ ${res2.message}
|
|
|
1587
1793
|
|
|
1588
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.
|
|
1589
1795
|
|
|
1590
|
-
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"
|
|
1591
1810
|
);
|
|
1592
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
|
+
}
|
|
1593
1830
|
const res = await ctx.client.completeRecovery(args.verificationId, {
|
|
1594
1831
|
...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
|
|
1595
1832
|
});
|
|
@@ -1601,6 +1838,7 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
|
|
|
1601
1838
|
apiBaseUrl: ctx.apiBaseUrl
|
|
1602
1839
|
});
|
|
1603
1840
|
return text(
|
|
1841
|
+
"domain_recovery_completed",
|
|
1604
1842
|
`Recovery complete.
|
|
1605
1843
|
Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
|
|
1606
1844
|
Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
|
|
@@ -1608,56 +1846,16 @@ Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (pr
|
|
|
1608
1846
|
A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
|
|
1609
1847
|
` + credentialGitReminder(ctx.projectDir) + `
|
|
1610
1848
|
Download the current site content (signed URL):
|
|
1611
|
-
${res.archiveUrl}
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
"set_billing_plan",
|
|
1620
|
-
{
|
|
1621
|
-
description: `Change this site's hosting plan, cancel/re-enable renewal, or cancel immediately. 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; cancelNow: true ends it IMMEDIATELY \u2014 the site reverts to a free 24h site at once and all paid data (custom domain bindings included) is removed, with no refund of the current period. The API returns the consequences first; explicit owner confirmation (confirm: true) is required before anything is applied.`,
|
|
1622
|
-
inputSchema: {
|
|
1623
|
-
plan: planEnum.optional().describe("Target monthly plan."),
|
|
1624
|
-
cancelRenewal: z.boolean().optional().describe(
|
|
1625
|
-
"true: cancel renewal (the site stays permanent to the end of the paid month, then reverts to free). false: re-enable renewal."
|
|
1626
|
-
),
|
|
1627
|
-
cancelNow: z.boolean().optional().describe("End the subscription immediately; the site reverts to free right away."),
|
|
1628
|
-
confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
|
|
1629
|
-
}
|
|
1630
|
-
},
|
|
1631
|
-
async (args) => {
|
|
1632
|
-
try {
|
|
1633
|
-
const site = requireSiteFile(ctx);
|
|
1634
|
-
const req = {
|
|
1635
|
-
...args.plan !== void 0 ? { plan: args.plan } : {},
|
|
1636
|
-
...args.cancelRenewal !== void 0 ? { cancelRenewal: args.cancelRenewal } : {},
|
|
1637
|
-
...args.cancelNow !== void 0 ? { cancelNow: args.cancelNow } : {},
|
|
1638
|
-
confirm: args.confirm === true
|
|
1639
|
-
};
|
|
1640
|
-
try {
|
|
1641
|
-
const res = await ctx.client.setBillingPlan(site.siteId, site.credential, req);
|
|
1642
|
-
return textJson(
|
|
1643
|
-
`Billing updated for site ${res.siteId} (mode: ${res.mode}).
|
|
1644
|
-
Consequences:
|
|
1645
|
-
${res.consequences.map((c) => `- ${c}`).join("\n")}
|
|
1646
|
-
Result:`,
|
|
1647
|
-
res
|
|
1648
|
-
);
|
|
1649
|
-
} catch (e) {
|
|
1650
|
-
if (isSakupaError(e) && e.code === "confirmation_required") {
|
|
1651
|
-
return text(
|
|
1652
|
-
`Confirmation required before changing the billing plan \u2014 nothing was applied.
|
|
1653
|
-
|
|
1654
|
-
${e.message}
|
|
1655
|
-
` + (e.details !== void 0 ? `${JSON.stringify(e.details, null, 2)}
|
|
1656
|
-
` : "") + "\nPlease show these consequences to the user and, after their explicit confirmation, re-run set_billing_plan with the same arguments plus confirm: true."
|
|
1657
|
-
);
|
|
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
|
|
1658
1857
|
}
|
|
1659
|
-
|
|
1660
|
-
}
|
|
1858
|
+
);
|
|
1661
1859
|
} catch (e) {
|
|
1662
1860
|
return toolError(e);
|
|
1663
1861
|
}
|
|
@@ -1667,11 +1865,13 @@ ${e.message}
|
|
|
1667
1865
|
"create_support_ticket",
|
|
1668
1866
|
{
|
|
1669
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 },
|
|
1670
1870
|
inputSchema: {
|
|
1671
1871
|
category: ticketCategoryEnum,
|
|
1672
|
-
subject:
|
|
1673
|
-
description:
|
|
1674
|
-
contactEmail:
|
|
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.")
|
|
1675
1875
|
}
|
|
1676
1876
|
},
|
|
1677
1877
|
async (args) => {
|
|
@@ -1684,7 +1884,11 @@ ${e.message}
|
|
|
1684
1884
|
description: args.description,
|
|
1685
1885
|
...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
|
|
1686
1886
|
});
|
|
1687
|
-
return text(
|
|
1887
|
+
return text(
|
|
1888
|
+
"support_ticket_created",
|
|
1889
|
+
`Support ticket created: ${res.ticketId} (status: ${res.status}).`,
|
|
1890
|
+
{ ticketId: res.ticketId, status: res.status }
|
|
1891
|
+
);
|
|
1688
1892
|
} catch (e) {
|
|
1689
1893
|
return toolError(e);
|
|
1690
1894
|
}
|
|
@@ -1694,15 +1898,17 @@ ${e.message}
|
|
|
1694
1898
|
"report_bug",
|
|
1695
1899
|
{
|
|
1696
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 },
|
|
1697
1903
|
inputSchema: {
|
|
1698
|
-
toolName:
|
|
1699
|
-
errorCode:
|
|
1700
|
-
errorMessage:
|
|
1701
|
-
requestId:
|
|
1702
|
-
deploymentId:
|
|
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(),
|
|
1703
1909
|
severity: severityEnum.optional(),
|
|
1704
|
-
description:
|
|
1705
|
-
confirmSubmit:
|
|
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.")
|
|
1706
1912
|
}
|
|
1707
1913
|
},
|
|
1708
1914
|
async (args) => {
|
|
@@ -1728,14 +1934,18 @@ ${e.message}
|
|
|
1728
1934
|
};
|
|
1729
1935
|
if (args.confirmSubmit !== true) {
|
|
1730
1936
|
return textJson(
|
|
1937
|
+
"bug_report_preview_ready",
|
|
1731
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.",
|
|
1732
|
-
payload
|
|
1939
|
+
payload,
|
|
1940
|
+
"preview"
|
|
1733
1941
|
);
|
|
1734
1942
|
}
|
|
1735
1943
|
const res = await ctx.client.reportBug(payload, site?.credential);
|
|
1736
1944
|
return text(
|
|
1945
|
+
"bug_report_submitted",
|
|
1737
1946
|
`Bug report submitted. Ticket: ${res.ticketId}
|
|
1738
|
-
Summary: ${res.sanitizedSummary}
|
|
1947
|
+
Summary: ${res.sanitizedSummary}`,
|
|
1948
|
+
{ ticketId: res.ticketId, sanitizedSummary: res.sanitizedSummary }
|
|
1739
1949
|
);
|
|
1740
1950
|
} catch (e) {
|
|
1741
1951
|
return toolError(e);
|
|
@@ -1744,6 +1954,132 @@ Summary: ${res.sanitizedSummary}`
|
|
|
1744
1954
|
);
|
|
1745
1955
|
}
|
|
1746
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
|
+
|
|
1747
2083
|
// src/transport.ts
|
|
1748
2084
|
var FetchTransport = class {
|
|
1749
2085
|
baseUrl;
|
|
@@ -1809,14 +2145,17 @@ Workflow:
|
|
|
1809
2145
|
management credential in .sakupa/site.json. Deploying again updates the site and refreshes
|
|
1810
2146
|
its validity; refresh_site extends validity without uploading.
|
|
1811
2147
|
3. To make the site PERMANENT, subscribe it to a monthly hosting plan (subscribe_site ->
|
|
1812
|
-
Stripe-hosted checkout; water/personal/share/business
|
|
1813
|
-
|
|
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.
|
|
1814
2152
|
4. Optionally bind a custom domain to the subscribed site (bind_domain): an included extra
|
|
1815
2153
|
serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
|
|
1816
2154
|
first verified request wins; unverified requests expire after 72 hours. billing_status,
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
paid
|
|
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.
|
|
1820
2159
|
|
|
1821
2160
|
Safety boundaries:
|
|
1822
2161
|
- Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
|
|
@@ -1824,7 +2163,9 @@ Safety boundaries:
|
|
|
1824
2163
|
- Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
|
|
1825
2164
|
- A subscription never grants domain ownership; only DNS verification does.
|
|
1826
2165
|
- The management credential lives only in .sakupa/site.json; never share or upload it. Without
|
|
1827
|
-
a bound custom domain, a lost credential is unrecoverable by design
|
|
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.`;
|
|
1828
2169
|
function createSakupaMcpServer(opts) {
|
|
1829
2170
|
const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
|
|
1830
2171
|
const server = new McpServer(
|
|
@@ -1836,6 +2177,11 @@ function createSakupaMcpServer(opts) {
|
|
|
1836
2177
|
projectDir: opts.projectDir,
|
|
1837
2178
|
apiBaseUrl: opts.apiBaseUrl
|
|
1838
2179
|
});
|
|
2180
|
+
registerBillingTools(server, {
|
|
2181
|
+
client,
|
|
2182
|
+
projectDir: opts.projectDir,
|
|
2183
|
+
apiBaseUrl: opts.apiBaseUrl
|
|
2184
|
+
});
|
|
1839
2185
|
return server;
|
|
1840
2186
|
}
|
|
1841
2187
|
|