@sakupa/mcp 0.6.0 → 0.7.1
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 +268 -108
- package/dist/index.js +269 -110
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -7,6 +7,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
7
7
|
var SERVICE_DOMAIN = "sakupa.com";
|
|
8
8
|
var DEFAULT_API_BASE_URL = "https://api.sakupa.com";
|
|
9
9
|
var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
|
|
10
|
+
var TEST_ACCESS_HEADER = "x-sakupa-test-token";
|
|
10
11
|
var FREE_SITE_TTL_HOURS = 24;
|
|
11
12
|
var FREE_SITE_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
|
|
12
13
|
var PAID_SITE_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
|
|
@@ -127,7 +128,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
127
128
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
128
129
|
|
|
129
130
|
// ../core/dist/domain/version.js
|
|
130
|
-
var SAKUPA_MCP_VERSION = "0.
|
|
131
|
+
var SAKUPA_MCP_VERSION = "0.7.1";
|
|
131
132
|
|
|
132
133
|
// ../core/dist/domain/errors.js
|
|
133
134
|
var HTTP_STATUS = {
|
|
@@ -452,18 +453,6 @@ async function sha256Hex(bytes) {
|
|
|
452
453
|
return hex;
|
|
453
454
|
}
|
|
454
455
|
|
|
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
|
-
};
|
|
466
|
-
|
|
467
456
|
// ../core/dist/dto.js
|
|
468
457
|
var CREDENTIAL_HEADER = "x-sakupa-credential";
|
|
469
458
|
var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
|
|
@@ -473,12 +462,31 @@ var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
|
|
|
473
462
|
var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
|
474
463
|
var utf8Encoder = new TextEncoder();
|
|
475
464
|
|
|
476
|
-
// ../core/dist/services/authorization.js
|
|
477
|
-
var AUTHORIZATION_TTL_MS = 15 * 60 * 1e3;
|
|
478
|
-
|
|
479
465
|
// ../core/dist/services/subscriptions.js
|
|
480
466
|
var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
|
|
481
467
|
|
|
468
|
+
// src/config.ts
|
|
469
|
+
var TEST_API_BASE_URL = "https://api-test.sakupa.com";
|
|
470
|
+
function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
471
|
+
const apiBaseUrl = (env["SAKUPA_API_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
472
|
+
const projectDir = env["SAKUPA_PROJECT_DIR"] ?? cwd;
|
|
473
|
+
const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
|
|
474
|
+
if (apiBaseUrl === TEST_API_BASE_URL) {
|
|
475
|
+
if (testAccessToken.length === 0) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
"The Sakupa Test API requires SAKUPA_TEST_ACCESS_TOKEN. Anonymous Test access is disabled."
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return { apiBaseUrl, projectDir, testAccessToken };
|
|
481
|
+
}
|
|
482
|
+
if (testAccessToken.length > 0) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
`SAKUPA_TEST_ACCESS_TOKEN may only be used with ${TEST_API_BASE_URL}. Remove it before connecting to any other API.`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
return { apiBaseUrl, projectDir };
|
|
488
|
+
}
|
|
489
|
+
|
|
482
490
|
// src/server.ts
|
|
483
491
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
484
492
|
|
|
@@ -574,8 +582,18 @@ var HttpApiClient = class {
|
|
|
574
582
|
credential
|
|
575
583
|
});
|
|
576
584
|
}
|
|
577
|
-
async deleteSite(siteId, credential) {
|
|
578
|
-
|
|
585
|
+
async deleteSite(siteId, credential, req) {
|
|
586
|
+
return this.call("POST", `/v1/sites/${encodeURIComponent(siteId)}/delete`, {
|
|
587
|
+
credential,
|
|
588
|
+
body: req
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
async previewDeleteSite(siteId, credential, req) {
|
|
592
|
+
return this.call(
|
|
593
|
+
"POST",
|
|
594
|
+
`/v1/sites/${encodeURIComponent(siteId)}/delete/preview`,
|
|
595
|
+
{ credential, body: req }
|
|
596
|
+
);
|
|
579
597
|
}
|
|
580
598
|
async bindDomain(credential, req) {
|
|
581
599
|
return this.call("POST", "/v1/domains/bind", { credential, body: req });
|
|
@@ -587,6 +605,20 @@ var HttpApiClient = class {
|
|
|
587
605
|
{ credential }
|
|
588
606
|
);
|
|
589
607
|
}
|
|
608
|
+
async unbindDomain(siteId, credential, req) {
|
|
609
|
+
return this.call(
|
|
610
|
+
"POST",
|
|
611
|
+
`/v1/sites/${encodeURIComponent(siteId)}/domain/unbind`,
|
|
612
|
+
{ credential, body: req }
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
async previewUnbindDomain(siteId, credential, req) {
|
|
616
|
+
return this.call(
|
|
617
|
+
"POST",
|
|
618
|
+
`/v1/sites/${encodeURIComponent(siteId)}/domain/unbind/preview`,
|
|
619
|
+
{ credential, body: req }
|
|
620
|
+
);
|
|
621
|
+
}
|
|
590
622
|
async recoverDomain(req) {
|
|
591
623
|
return this.call("POST", "/v1/domains/recover", { body: req });
|
|
592
624
|
}
|
|
@@ -629,10 +661,10 @@ var HttpApiClient = class {
|
|
|
629
661
|
async getBillingPlanCatalog() {
|
|
630
662
|
return this.call("GET", "/v1/billing/plans");
|
|
631
663
|
}
|
|
632
|
-
async
|
|
664
|
+
async changeSubscriptionPlan(credential, req) {
|
|
633
665
|
return this.call(
|
|
634
666
|
"POST",
|
|
635
|
-
`/v1/sites/${encodeURIComponent(siteId)}/billing/
|
|
667
|
+
`/v1/sites/${encodeURIComponent(req.siteId)}/billing/plan-change`,
|
|
636
668
|
{ credential, body: req }
|
|
637
669
|
);
|
|
638
670
|
}
|
|
@@ -1130,6 +1162,12 @@ function writeSiteFile(projectDir, file) {
|
|
|
1130
1162
|
} catch {
|
|
1131
1163
|
}
|
|
1132
1164
|
}
|
|
1165
|
+
function deleteSiteFile(projectDir) {
|
|
1166
|
+
const path = siteFilePath(projectDir);
|
|
1167
|
+
if (existsSync(path)) {
|
|
1168
|
+
rmSync(path, { force: true });
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1133
1171
|
function isInsideGitRepo(projectDir) {
|
|
1134
1172
|
let dir = projectDir;
|
|
1135
1173
|
for (; ; ) {
|
|
@@ -1202,14 +1240,32 @@ function requireSiteFile(ctx) {
|
|
|
1202
1240
|
}
|
|
1203
1241
|
function toolError(e) {
|
|
1204
1242
|
const errorCode = isSakupaError(e) ? e.code : "internal";
|
|
1205
|
-
const retryable =
|
|
1206
|
-
const
|
|
1243
|
+
const retryable = errorCode === "rate_limited" || errorCode === "internal";
|
|
1244
|
+
const safeDetailKeys = /* @__PURE__ */ new Set([
|
|
1245
|
+
"retryAfterSeconds",
|
|
1246
|
+
"reasonCode",
|
|
1247
|
+
"currentStatus",
|
|
1248
|
+
"expectedStatus",
|
|
1249
|
+
"minimumVersion",
|
|
1250
|
+
"currentVersion"
|
|
1251
|
+
]);
|
|
1252
|
+
const rawDetails = isSakupaError(e) && e.details && typeof e.details === "object" ? e.details : void 0;
|
|
1253
|
+
const safeDetails = rawDetails ? Object.fromEntries(
|
|
1254
|
+
Object.entries(rawDetails).filter(
|
|
1255
|
+
([key, value]) => safeDetailKeys.has(key) && (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
1256
|
+
)
|
|
1257
|
+
) : void 0;
|
|
1258
|
+
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";
|
|
1207
1259
|
const result = structuredToolResult({
|
|
1208
1260
|
schemaVersion: 1,
|
|
1209
1261
|
outcome: "failed",
|
|
1210
1262
|
resultCode: `error_${errorCode}`,
|
|
1211
1263
|
summary: safeSummary,
|
|
1212
|
-
data: {
|
|
1264
|
+
data: {
|
|
1265
|
+
errorCode,
|
|
1266
|
+
retryable,
|
|
1267
|
+
...safeDetails && Object.keys(safeDetails).length > 0 ? { details: safeDetails } : {}
|
|
1268
|
+
},
|
|
1213
1269
|
nextActions: []
|
|
1214
1270
|
});
|
|
1215
1271
|
return { ...result, isError: true };
|
|
@@ -1957,7 +2013,6 @@ Summary: ${res.sanitizedSummary}`,
|
|
|
1957
2013
|
// src/tools/billing.ts
|
|
1958
2014
|
import { z as z3 } from "zod";
|
|
1959
2015
|
var plan = z3.enum(["water", "personal", "share", "business"]);
|
|
1960
|
-
var trigger = z3.enum(["actual_overage", "credential_automation"]);
|
|
1961
2016
|
function registerBillingTools(server, ctx) {
|
|
1962
2017
|
server.registerTool(
|
|
1963
2018
|
"list_billing_plans",
|
|
@@ -1984,22 +2039,81 @@ function registerBillingTools(server, ctx) {
|
|
|
1984
2039
|
}
|
|
1985
2040
|
);
|
|
1986
2041
|
server.registerTool(
|
|
1987
|
-
"
|
|
2042
|
+
"change_subscription_plan",
|
|
2043
|
+
{
|
|
2044
|
+
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.",
|
|
2045
|
+
inputSchema: {
|
|
2046
|
+
targetPlan: plan,
|
|
2047
|
+
operationId: z3.string().min(1)
|
|
2048
|
+
},
|
|
2049
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2050
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
2051
|
+
},
|
|
2052
|
+
async (args) => {
|
|
2053
|
+
try {
|
|
2054
|
+
const site = requireSiteFile(ctx);
|
|
2055
|
+
const result = await ctx.client.changeSubscriptionPlan(site.credential, {
|
|
2056
|
+
siteId: site.siteId,
|
|
2057
|
+
targetPlan: args.targetPlan,
|
|
2058
|
+
operationId: args.operationId
|
|
2059
|
+
});
|
|
2060
|
+
return structuredToolResult({
|
|
2061
|
+
schemaVersion: 1,
|
|
2062
|
+
outcome: "waiting_user",
|
|
2063
|
+
resultCode: "stripe_plan_change_confirmation_required",
|
|
2064
|
+
operationId: args.operationId,
|
|
2065
|
+
summary: `\u5DF2\u751F\u6210\u4ECE ${result.currentPlan} \u5230 ${result.targetPlan} \u7684 Stripe \u786E\u8BA4\u94FE\u63A5\uFF1B\u8BA2\u9605\u5C1A\u672A\u53D8\u66F4\u3002`,
|
|
2066
|
+
data: { result },
|
|
2067
|
+
userAction: {
|
|
2068
|
+
type: "open_url",
|
|
2069
|
+
provider: "stripe",
|
|
2070
|
+
url: result.portalUrl,
|
|
2071
|
+
expectedOutcome: "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u786E\u8BA4\u540E\uFF0C\u7531 webhook \u66F4\u65B0 Sakupa \u8BA2\u9605\u72B6\u6001"
|
|
2072
|
+
},
|
|
2073
|
+
nextActions: [{ tool: "billing_status", allowed: true }]
|
|
2074
|
+
});
|
|
2075
|
+
} catch (error) {
|
|
2076
|
+
return toolError(error);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// src/tools/lifecycle.ts
|
|
2083
|
+
import { z as z4 } from "zod";
|
|
2084
|
+
var deleteConfirmation = z4.object({
|
|
2085
|
+
siteId: z4.string().min(1),
|
|
2086
|
+
expectedSiteUpdatedAt: z4.string().datetime(),
|
|
2087
|
+
expectedStatus: z4.enum(["active", "expired", "deleted"]),
|
|
2088
|
+
expectedMode: z4.enum(["free", "paid"]),
|
|
2089
|
+
expectedServingMode: z4.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
|
|
2090
|
+
expectedShortId: z4.string().optional(),
|
|
2091
|
+
expectedSubscriptionStatus: z4.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
|
|
2092
|
+
expectedPlan: z4.enum(["water", "personal", "share", "business"]).optional(),
|
|
2093
|
+
expectedCancelAtPeriodEnd: z4.boolean().optional(),
|
|
2094
|
+
expectedCurrentPeriodEnd: z4.string().datetime().optional(),
|
|
2095
|
+
expectedLastDeploymentId: z4.string().optional(),
|
|
2096
|
+
expectedBoundHostnames: z4.array(z4.string()),
|
|
2097
|
+
acknowledge: z4.literal("delete_site_and_cancel_renewal")
|
|
2098
|
+
});
|
|
2099
|
+
var unbindConfirmation = z4.object({
|
|
2100
|
+
siteId: z4.string().min(1),
|
|
2101
|
+
bindingId: z4.string().min(1),
|
|
2102
|
+
expectedBindingUpdatedAt: z4.string().datetime(),
|
|
2103
|
+
expectedBindingStatus: z4.enum(["provisioning", "active"]),
|
|
2104
|
+
apexDomain: z4.string().min(1),
|
|
2105
|
+
expectedBoundHostnames: z4.array(z4.string()),
|
|
2106
|
+
acknowledge: z4.literal("unbind_domain_and_remove_custom_hostnames")
|
|
2107
|
+
});
|
|
2108
|
+
function registerLifecycleTools(server, ctx) {
|
|
2109
|
+
server.registerTool(
|
|
2110
|
+
"delete_site",
|
|
1988
2111
|
{
|
|
1989
|
-
description: "
|
|
2112
|
+
description: "Preview or execute deletion of this Sakupa site. Execution requires an exact server-validated confirmation bound to the current site state.",
|
|
1990
2113
|
inputSchema: {
|
|
1991
|
-
action:
|
|
1992
|
-
|
|
1993
|
-
|
|
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()
|
|
2114
|
+
action: z4.enum(["preview", "confirm"]),
|
|
2115
|
+
operationId: z4.string().min(1).optional(),
|
|
2116
|
+
confirmation: deleteConfirmation.optional()
|
|
2003
2117
|
},
|
|
2004
2118
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2005
2119
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
|
|
@@ -2007,71 +2121,92 @@ function registerBillingTools(server, ctx) {
|
|
|
2007
2121
|
async (args) => {
|
|
2008
2122
|
try {
|
|
2009
2123
|
const site = requireSiteFile(ctx);
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
request = {
|
|
2016
|
-
action: args.action,
|
|
2017
|
-
targetPlan: args.targetPlan,
|
|
2124
|
+
if (!args.operationId) {
|
|
2125
|
+
throw new Error("operationId is required for delete_site");
|
|
2126
|
+
}
|
|
2127
|
+
if (args.action === "preview") {
|
|
2128
|
+
const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
|
|
2018
2129
|
operationId: args.operationId
|
|
2019
|
-
};
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2130
|
+
});
|
|
2131
|
+
return structuredToolResult({
|
|
2132
|
+
schemaVersion: 1,
|
|
2133
|
+
outcome: "waiting_user",
|
|
2134
|
+
resultCode: "delete_site_confirmation_required",
|
|
2135
|
+
operationId: args.operationId,
|
|
2136
|
+
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",
|
|
2137
|
+
data: { preview },
|
|
2138
|
+
nextActions: [
|
|
2139
|
+
{ tool: "delete_site", allowed: true, reasonCode: "exact_confirmation_required" }
|
|
2140
|
+
]
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
if (!args.confirmation) throw new Error("confirmation is required for confirm");
|
|
2144
|
+
const result = await ctx.client.deleteSite(site.siteId, site.credential, {
|
|
2145
|
+
operationId: args.operationId,
|
|
2146
|
+
confirmation: args.confirmation
|
|
2147
|
+
});
|
|
2148
|
+
deleteSiteFile(ctx.projectDir);
|
|
2149
|
+
return structuredToolResult({
|
|
2150
|
+
schemaVersion: 1,
|
|
2151
|
+
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2152
|
+
resultCode: "site_deleted",
|
|
2153
|
+
operationId: args.operationId,
|
|
2154
|
+
summary: "\u7AD9\u70B9\u5DF2\u5220\u9664\uFF0C\u672C\u5730\u7BA1\u7406\u51ED\u8BC1\u6587\u4EF6\u5DF2\u79FB\u9664\u3002",
|
|
2155
|
+
data: { result },
|
|
2156
|
+
nextActions: []
|
|
2157
|
+
});
|
|
2158
|
+
} catch (error) {
|
|
2159
|
+
return toolError(error);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
);
|
|
2163
|
+
server.registerTool(
|
|
2164
|
+
"unbind_domain",
|
|
2165
|
+
{
|
|
2166
|
+
description: "Preview or execute removal of the custom apex/www serving surface while preserving the subscription and permanent Sakupa URL.",
|
|
2167
|
+
inputSchema: {
|
|
2168
|
+
action: z4.enum(["preview", "confirm"]),
|
|
2169
|
+
operationId: z4.string().min(1).optional(),
|
|
2170
|
+
confirmation: unbindConfirmation.optional()
|
|
2171
|
+
},
|
|
2172
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2173
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
|
|
2174
|
+
},
|
|
2175
|
+
async (args) => {
|
|
2176
|
+
try {
|
|
2177
|
+
const site = requireSiteFile(ctx);
|
|
2178
|
+
if (!args.operationId) throw new Error("operationId is required for unbind_domain");
|
|
2179
|
+
if (args.action === "preview") {
|
|
2180
|
+
const preview = await ctx.client.previewUnbindDomain(site.siteId, site.credential, {
|
|
2031
2181
|
operationId: args.operationId
|
|
2032
|
-
};
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
throw new SakupaError(
|
|
2038
|
-
"invalid_request",
|
|
2039
|
-
"targetPlan is required for request_authorized_upgrade"
|
|
2040
|
-
);
|
|
2041
|
-
}
|
|
2042
|
-
request = {
|
|
2043
|
-
action: args.action,
|
|
2182
|
+
});
|
|
2183
|
+
return structuredToolResult({
|
|
2184
|
+
schemaVersion: 1,
|
|
2185
|
+
outcome: "waiting_user",
|
|
2186
|
+
resultCode: "unbind_domain_confirmation_required",
|
|
2044
2187
|
operationId: args.operationId,
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2188
|
+
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",
|
|
2189
|
+
data: { preview },
|
|
2190
|
+
nextActions: [
|
|
2191
|
+
{ tool: "unbind_domain", allowed: true, reasonCode: "exact_confirmation_required" }
|
|
2192
|
+
]
|
|
2193
|
+
});
|
|
2050
2194
|
}
|
|
2051
|
-
|
|
2052
|
-
const
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
const
|
|
2057
|
-
|
|
2195
|
+
if (!args.confirmation) throw new Error("confirmation is required for confirm");
|
|
2196
|
+
const result = await ctx.client.unbindDomain(site.siteId, site.credential, {
|
|
2197
|
+
operationId: args.operationId,
|
|
2198
|
+
confirmation: args.confirmation
|
|
2199
|
+
});
|
|
2200
|
+
const { boundDomain: _removed, ...remaining } = site;
|
|
2201
|
+
writeSiteFile(ctx.projectDir, remaining);
|
|
2058
2202
|
return structuredToolResult({
|
|
2059
2203
|
schemaVersion: 1,
|
|
2060
|
-
outcome,
|
|
2061
|
-
resultCode,
|
|
2204
|
+
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2205
|
+
resultCode: "domain_unbound",
|
|
2062
2206
|
operationId: args.operationId,
|
|
2063
|
-
summary:
|
|
2064
|
-
data: {
|
|
2065
|
-
|
|
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 }]
|
|
2207
|
+
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",
|
|
2208
|
+
data: { result },
|
|
2209
|
+
nextActions: [{ tool: "site_status", allowed: true }]
|
|
2075
2210
|
});
|
|
2076
2211
|
} catch (error) {
|
|
2077
2212
|
return toolError(error);
|
|
@@ -2083,8 +2218,23 @@ function registerBillingTools(server, ctx) {
|
|
|
2083
2218
|
// src/transport.ts
|
|
2084
2219
|
var FetchTransport = class {
|
|
2085
2220
|
baseUrl;
|
|
2086
|
-
|
|
2221
|
+
testAccessToken;
|
|
2222
|
+
constructor(baseUrl, options = {}) {
|
|
2087
2223
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
2224
|
+
if (options.testAccessToken && this.baseUrl !== TEST_API_BASE_URL) {
|
|
2225
|
+
throw new Error(`Test access credentials may only be sent to ${TEST_API_BASE_URL}.`);
|
|
2226
|
+
}
|
|
2227
|
+
this.testAccessToken = options.testAccessToken;
|
|
2228
|
+
}
|
|
2229
|
+
testAccessHeadersFor(_url) {
|
|
2230
|
+
if (!this.testAccessToken) return {};
|
|
2231
|
+
let target;
|
|
2232
|
+
try {
|
|
2233
|
+
target = new URL(_url);
|
|
2234
|
+
} catch {
|
|
2235
|
+
return {};
|
|
2236
|
+
}
|
|
2237
|
+
return target.origin === TEST_API_BASE_URL ? { [TEST_ACCESS_HEADER]: this.testAccessToken } : {};
|
|
2088
2238
|
}
|
|
2089
2239
|
async request(req) {
|
|
2090
2240
|
let url = `${this.baseUrl}${req.path}`;
|
|
@@ -2095,7 +2245,8 @@ var FetchTransport = class {
|
|
|
2095
2245
|
accept: "application/json",
|
|
2096
2246
|
[MCP_VERSION_HEADER]: MCP_VERSION,
|
|
2097
2247
|
...req.body !== void 0 ? { "content-type": "application/json" } : {},
|
|
2098
|
-
...req.headers
|
|
2248
|
+
...req.headers,
|
|
2249
|
+
...this.testAccessHeadersFor(url)
|
|
2099
2250
|
};
|
|
2100
2251
|
const res = await fetch(url, {
|
|
2101
2252
|
method: req.method,
|
|
@@ -2121,7 +2272,10 @@ var FetchTransport = class {
|
|
|
2121
2272
|
}
|
|
2122
2273
|
const res = await fetch(target.url, {
|
|
2123
2274
|
method: target.method,
|
|
2124
|
-
headers:
|
|
2275
|
+
headers: {
|
|
2276
|
+
...target.headers,
|
|
2277
|
+
...this.testAccessHeadersFor(target.url)
|
|
2278
|
+
},
|
|
2125
2279
|
body
|
|
2126
2280
|
});
|
|
2127
2281
|
if (!res.ok) {
|
|
@@ -2147,13 +2301,13 @@ Workflow:
|
|
|
2147
2301
|
3. To make the site PERMANENT, subscribe it to a monthly hosting plan (subscribe_site ->
|
|
2148
2302
|
Stripe-hosted checkout; water/personal/share/business). Paying makes the
|
|
2149
2303
|
{shortId}.sakupa.com URL permanent \u2014 that is what payment buys. Usage over the chosen plan
|
|
2150
|
-
shows an over-limit notice by default.
|
|
2151
|
-
|
|
2304
|
+
shows an over-limit notice by default. An external AI may periodically query usage and
|
|
2305
|
+
recommend a plan, but Sakupa never changes a subscription automatically.
|
|
2152
2306
|
4. Optionally bind a custom domain to the subscribed site (bind_domain): an included extra
|
|
2153
2307
|
serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
|
|
2154
2308
|
first verified request wins; unverified requests expire after 72 hours. billing_status,
|
|
2155
|
-
|
|
2156
|
-
changes are confirmed only on Stripe
|
|
2309
|
+
billing_status, change_subscription_plan, manage_billing and recover_domain_site manage the paid
|
|
2310
|
+
lifecycle. Plan changes are confirmed only on Stripe and synchronized by Stripe webhook.
|
|
2157
2311
|
A cancellation keeps the site paid through the current period. Sakupa reverts it to a free
|
|
2158
2312
|
24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
|
|
2159
2313
|
|
|
@@ -2167,7 +2321,9 @@ Safety boundaries:
|
|
|
2167
2321
|
Stripe's public no-code portal login, where the customer verifies the checkout email with a
|
|
2168
2322
|
Stripe one-time passcode; it never restores site authority.`;
|
|
2169
2323
|
function createSakupaMcpServer(opts) {
|
|
2170
|
-
const client = opts.client ?? new HttpApiClient(
|
|
2324
|
+
const client = opts.client ?? new HttpApiClient(
|
|
2325
|
+
new FetchTransport(opts.apiBaseUrl, { testAccessToken: opts.testAccessToken })
|
|
2326
|
+
);
|
|
2171
2327
|
const server = new McpServer(
|
|
2172
2328
|
{ name: "sakupa", version: MCP_VERSION },
|
|
2173
2329
|
{ instructions: INSTRUCTIONS }
|
|
@@ -2182,18 +2338,22 @@ function createSakupaMcpServer(opts) {
|
|
|
2182
2338
|
projectDir: opts.projectDir,
|
|
2183
2339
|
apiBaseUrl: opts.apiBaseUrl
|
|
2184
2340
|
});
|
|
2341
|
+
registerLifecycleTools(server, {
|
|
2342
|
+
client,
|
|
2343
|
+
projectDir: opts.projectDir,
|
|
2344
|
+
apiBaseUrl: opts.apiBaseUrl
|
|
2345
|
+
});
|
|
2185
2346
|
return server;
|
|
2186
2347
|
}
|
|
2187
2348
|
|
|
2188
2349
|
// src/bin.ts
|
|
2189
2350
|
async function main() {
|
|
2190
|
-
const
|
|
2191
|
-
const
|
|
2192
|
-
const server = createSakupaMcpServer({ apiBaseUrl, projectDir });
|
|
2351
|
+
const config = loadMcpRuntimeConfig();
|
|
2352
|
+
const server = createSakupaMcpServer(config);
|
|
2193
2353
|
const transport = new StdioServerTransport();
|
|
2194
2354
|
await server.connect(transport);
|
|
2195
2355
|
console.error(
|
|
2196
|
-
`[sakupa-mcp] v${MCP_VERSION} connected (api: ${apiBaseUrl}, project: ${projectDir})`
|
|
2356
|
+
`[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}, project: ${config.projectDir})`
|
|
2197
2357
|
);
|
|
2198
2358
|
}
|
|
2199
2359
|
main().catch((err) => {
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// ../core/dist/domain/constants.js
|
|
2
2
|
var SERVICE_DOMAIN = "sakupa.com";
|
|
3
|
+
var DEFAULT_API_BASE_URL = "https://api.sakupa.com";
|
|
3
4
|
var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
|
|
5
|
+
var TEST_ACCESS_HEADER = "x-sakupa-test-token";
|
|
4
6
|
var FREE_SITE_TTL_HOURS = 24;
|
|
5
7
|
var FREE_SITE_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
|
|
6
8
|
var PAID_SITE_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
|
|
@@ -121,7 +123,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
121
123
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
122
124
|
|
|
123
125
|
// ../core/dist/domain/version.js
|
|
124
|
-
var SAKUPA_MCP_VERSION = "0.
|
|
126
|
+
var SAKUPA_MCP_VERSION = "0.7.1";
|
|
125
127
|
|
|
126
128
|
// ../core/dist/domain/errors.js
|
|
127
129
|
var HTTP_STATUS = {
|
|
@@ -446,18 +448,6 @@ async function sha256Hex(bytes) {
|
|
|
446
448
|
return hex;
|
|
447
449
|
}
|
|
448
450
|
|
|
449
|
-
// ../core/dist/domain/billing-operation.js
|
|
450
|
-
var BILLING_MUTATION_LIMITS = {
|
|
451
|
-
globalPerTenMinutes: 20,
|
|
452
|
-
sitePerTenMinutes: 1,
|
|
453
|
-
perBillingPeriod: 3,
|
|
454
|
-
maxConcurrency: 2,
|
|
455
|
-
leaseSeconds: 30,
|
|
456
|
-
maxConsecutiveFailures: 5,
|
|
457
|
-
circuitBreakSeconds: 15 * 60,
|
|
458
|
-
providerTimeoutMs: 1e4
|
|
459
|
-
};
|
|
460
|
-
|
|
461
451
|
// ../core/dist/dto.js
|
|
462
452
|
var CREDENTIAL_HEADER = "x-sakupa-credential";
|
|
463
453
|
var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
|
|
@@ -467,9 +457,6 @@ var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
|
|
|
467
457
|
var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
|
468
458
|
var utf8Encoder = new TextEncoder();
|
|
469
459
|
|
|
470
|
-
// ../core/dist/services/authorization.js
|
|
471
|
-
var AUTHORIZATION_TTL_MS = 15 * 60 * 1e3;
|
|
472
|
-
|
|
473
460
|
// ../core/dist/services/subscriptions.js
|
|
474
461
|
var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
|
|
475
462
|
|
|
@@ -477,11 +464,48 @@ var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
|
|
|
477
464
|
var MCP_VERSION = SAKUPA_MCP_VERSION;
|
|
478
465
|
var CLIENT_TYPE = "sakupa-mcp";
|
|
479
466
|
|
|
467
|
+
// src/config.ts
|
|
468
|
+
var TEST_API_BASE_URL = "https://api-test.sakupa.com";
|
|
469
|
+
function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
470
|
+
const apiBaseUrl = (env["SAKUPA_API_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
471
|
+
const projectDir = env["SAKUPA_PROJECT_DIR"] ?? cwd;
|
|
472
|
+
const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
|
|
473
|
+
if (apiBaseUrl === TEST_API_BASE_URL) {
|
|
474
|
+
if (testAccessToken.length === 0) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
"The Sakupa Test API requires SAKUPA_TEST_ACCESS_TOKEN. Anonymous Test access is disabled."
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return { apiBaseUrl, projectDir, testAccessToken };
|
|
480
|
+
}
|
|
481
|
+
if (testAccessToken.length > 0) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`SAKUPA_TEST_ACCESS_TOKEN may only be used with ${TEST_API_BASE_URL}. Remove it before connecting to any other API.`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
return { apiBaseUrl, projectDir };
|
|
487
|
+
}
|
|
488
|
+
|
|
480
489
|
// src/transport.ts
|
|
481
490
|
var FetchTransport = class {
|
|
482
491
|
baseUrl;
|
|
483
|
-
|
|
492
|
+
testAccessToken;
|
|
493
|
+
constructor(baseUrl, options = {}) {
|
|
484
494
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
495
|
+
if (options.testAccessToken && this.baseUrl !== TEST_API_BASE_URL) {
|
|
496
|
+
throw new Error(`Test access credentials may only be sent to ${TEST_API_BASE_URL}.`);
|
|
497
|
+
}
|
|
498
|
+
this.testAccessToken = options.testAccessToken;
|
|
499
|
+
}
|
|
500
|
+
testAccessHeadersFor(_url) {
|
|
501
|
+
if (!this.testAccessToken) return {};
|
|
502
|
+
let target;
|
|
503
|
+
try {
|
|
504
|
+
target = new URL(_url);
|
|
505
|
+
} catch {
|
|
506
|
+
return {};
|
|
507
|
+
}
|
|
508
|
+
return target.origin === TEST_API_BASE_URL ? { [TEST_ACCESS_HEADER]: this.testAccessToken } : {};
|
|
485
509
|
}
|
|
486
510
|
async request(req) {
|
|
487
511
|
let url = `${this.baseUrl}${req.path}`;
|
|
@@ -492,7 +516,8 @@ var FetchTransport = class {
|
|
|
492
516
|
accept: "application/json",
|
|
493
517
|
[MCP_VERSION_HEADER]: MCP_VERSION,
|
|
494
518
|
...req.body !== void 0 ? { "content-type": "application/json" } : {},
|
|
495
|
-
...req.headers
|
|
519
|
+
...req.headers,
|
|
520
|
+
...this.testAccessHeadersFor(url)
|
|
496
521
|
};
|
|
497
522
|
const res = await fetch(url, {
|
|
498
523
|
method: req.method,
|
|
@@ -518,7 +543,10 @@ var FetchTransport = class {
|
|
|
518
543
|
}
|
|
519
544
|
const res = await fetch(target.url, {
|
|
520
545
|
method: target.method,
|
|
521
|
-
headers:
|
|
546
|
+
headers: {
|
|
547
|
+
...target.headers,
|
|
548
|
+
...this.testAccessHeadersFor(target.url)
|
|
549
|
+
},
|
|
522
550
|
body
|
|
523
551
|
});
|
|
524
552
|
if (!res.ok) {
|
|
@@ -622,8 +650,18 @@ var HttpApiClient = class {
|
|
|
622
650
|
credential
|
|
623
651
|
});
|
|
624
652
|
}
|
|
625
|
-
async deleteSite(siteId, credential) {
|
|
626
|
-
|
|
653
|
+
async deleteSite(siteId, credential, req) {
|
|
654
|
+
return this.call("POST", `/v1/sites/${encodeURIComponent(siteId)}/delete`, {
|
|
655
|
+
credential,
|
|
656
|
+
body: req
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
async previewDeleteSite(siteId, credential, req) {
|
|
660
|
+
return this.call(
|
|
661
|
+
"POST",
|
|
662
|
+
`/v1/sites/${encodeURIComponent(siteId)}/delete/preview`,
|
|
663
|
+
{ credential, body: req }
|
|
664
|
+
);
|
|
627
665
|
}
|
|
628
666
|
async bindDomain(credential, req) {
|
|
629
667
|
return this.call("POST", "/v1/domains/bind", { credential, body: req });
|
|
@@ -635,6 +673,20 @@ var HttpApiClient = class {
|
|
|
635
673
|
{ credential }
|
|
636
674
|
);
|
|
637
675
|
}
|
|
676
|
+
async unbindDomain(siteId, credential, req) {
|
|
677
|
+
return this.call(
|
|
678
|
+
"POST",
|
|
679
|
+
`/v1/sites/${encodeURIComponent(siteId)}/domain/unbind`,
|
|
680
|
+
{ credential, body: req }
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
async previewUnbindDomain(siteId, credential, req) {
|
|
684
|
+
return this.call(
|
|
685
|
+
"POST",
|
|
686
|
+
`/v1/sites/${encodeURIComponent(siteId)}/domain/unbind/preview`,
|
|
687
|
+
{ credential, body: req }
|
|
688
|
+
);
|
|
689
|
+
}
|
|
638
690
|
async recoverDomain(req) {
|
|
639
691
|
return this.call("POST", "/v1/domains/recover", { body: req });
|
|
640
692
|
}
|
|
@@ -677,10 +729,10 @@ var HttpApiClient = class {
|
|
|
677
729
|
async getBillingPlanCatalog() {
|
|
678
730
|
return this.call("GET", "/v1/billing/plans");
|
|
679
731
|
}
|
|
680
|
-
async
|
|
732
|
+
async changeSubscriptionPlan(credential, req) {
|
|
681
733
|
return this.call(
|
|
682
734
|
"POST",
|
|
683
|
-
`/v1/sites/${encodeURIComponent(siteId)}/billing/
|
|
735
|
+
`/v1/sites/${encodeURIComponent(req.siteId)}/billing/plan-change`,
|
|
684
736
|
{ credential, body: req }
|
|
685
737
|
);
|
|
686
738
|
}
|
|
@@ -1246,14 +1298,32 @@ function requireSiteFile(ctx) {
|
|
|
1246
1298
|
}
|
|
1247
1299
|
function toolError(e) {
|
|
1248
1300
|
const errorCode = isSakupaError(e) ? e.code : "internal";
|
|
1249
|
-
const retryable =
|
|
1250
|
-
const
|
|
1301
|
+
const retryable = errorCode === "rate_limited" || errorCode === "internal";
|
|
1302
|
+
const safeDetailKeys = /* @__PURE__ */ new Set([
|
|
1303
|
+
"retryAfterSeconds",
|
|
1304
|
+
"reasonCode",
|
|
1305
|
+
"currentStatus",
|
|
1306
|
+
"expectedStatus",
|
|
1307
|
+
"minimumVersion",
|
|
1308
|
+
"currentVersion"
|
|
1309
|
+
]);
|
|
1310
|
+
const rawDetails = isSakupaError(e) && e.details && typeof e.details === "object" ? e.details : void 0;
|
|
1311
|
+
const safeDetails = rawDetails ? Object.fromEntries(
|
|
1312
|
+
Object.entries(rawDetails).filter(
|
|
1313
|
+
([key, value]) => safeDetailKeys.has(key) && (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
1314
|
+
)
|
|
1315
|
+
) : void 0;
|
|
1316
|
+
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";
|
|
1251
1317
|
const result = structuredToolResult({
|
|
1252
1318
|
schemaVersion: 1,
|
|
1253
1319
|
outcome: "failed",
|
|
1254
1320
|
resultCode: `error_${errorCode}`,
|
|
1255
1321
|
summary: safeSummary,
|
|
1256
|
-
data: {
|
|
1322
|
+
data: {
|
|
1323
|
+
errorCode,
|
|
1324
|
+
retryable,
|
|
1325
|
+
...safeDetails && Object.keys(safeDetails).length > 0 ? { details: safeDetails } : {}
|
|
1326
|
+
},
|
|
1257
1327
|
nextActions: []
|
|
1258
1328
|
});
|
|
1259
1329
|
return { ...result, isError: true };
|
|
@@ -2002,13 +2072,148 @@ Summary: ${res.sanitizedSummary}`,
|
|
|
2002
2072
|
);
|
|
2003
2073
|
}
|
|
2004
2074
|
|
|
2075
|
+
// src/tools/lifecycle.ts
|
|
2076
|
+
import { z as z3 } from "zod";
|
|
2077
|
+
var deleteConfirmation = z3.object({
|
|
2078
|
+
siteId: z3.string().min(1),
|
|
2079
|
+
expectedSiteUpdatedAt: z3.string().datetime(),
|
|
2080
|
+
expectedStatus: z3.enum(["active", "expired", "deleted"]),
|
|
2081
|
+
expectedMode: z3.enum(["free", "paid"]),
|
|
2082
|
+
expectedServingMode: z3.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
|
|
2083
|
+
expectedShortId: z3.string().optional(),
|
|
2084
|
+
expectedSubscriptionStatus: z3.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
|
|
2085
|
+
expectedPlan: z3.enum(["water", "personal", "share", "business"]).optional(),
|
|
2086
|
+
expectedCancelAtPeriodEnd: z3.boolean().optional(),
|
|
2087
|
+
expectedCurrentPeriodEnd: z3.string().datetime().optional(),
|
|
2088
|
+
expectedLastDeploymentId: z3.string().optional(),
|
|
2089
|
+
expectedBoundHostnames: z3.array(z3.string()),
|
|
2090
|
+
acknowledge: z3.literal("delete_site_and_cancel_renewal")
|
|
2091
|
+
});
|
|
2092
|
+
var unbindConfirmation = z3.object({
|
|
2093
|
+
siteId: z3.string().min(1),
|
|
2094
|
+
bindingId: z3.string().min(1),
|
|
2095
|
+
expectedBindingUpdatedAt: z3.string().datetime(),
|
|
2096
|
+
expectedBindingStatus: z3.enum(["provisioning", "active"]),
|
|
2097
|
+
apexDomain: z3.string().min(1),
|
|
2098
|
+
expectedBoundHostnames: z3.array(z3.string()),
|
|
2099
|
+
acknowledge: z3.literal("unbind_domain_and_remove_custom_hostnames")
|
|
2100
|
+
});
|
|
2101
|
+
function registerLifecycleTools(server, ctx) {
|
|
2102
|
+
server.registerTool(
|
|
2103
|
+
"delete_site",
|
|
2104
|
+
{
|
|
2105
|
+
description: "Preview or execute deletion of this Sakupa site. Execution requires an exact server-validated confirmation bound to the current site state.",
|
|
2106
|
+
inputSchema: {
|
|
2107
|
+
action: z3.enum(["preview", "confirm"]),
|
|
2108
|
+
operationId: z3.string().min(1).optional(),
|
|
2109
|
+
confirmation: deleteConfirmation.optional()
|
|
2110
|
+
},
|
|
2111
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2112
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
|
|
2113
|
+
},
|
|
2114
|
+
async (args) => {
|
|
2115
|
+
try {
|
|
2116
|
+
const site = requireSiteFile(ctx);
|
|
2117
|
+
if (!args.operationId) {
|
|
2118
|
+
throw new Error("operationId is required for delete_site");
|
|
2119
|
+
}
|
|
2120
|
+
if (args.action === "preview") {
|
|
2121
|
+
const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
|
|
2122
|
+
operationId: args.operationId
|
|
2123
|
+
});
|
|
2124
|
+
return structuredToolResult({
|
|
2125
|
+
schemaVersion: 1,
|
|
2126
|
+
outcome: "waiting_user",
|
|
2127
|
+
resultCode: "delete_site_confirmation_required",
|
|
2128
|
+
operationId: args.operationId,
|
|
2129
|
+
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",
|
|
2130
|
+
data: { preview },
|
|
2131
|
+
nextActions: [
|
|
2132
|
+
{ tool: "delete_site", allowed: true, reasonCode: "exact_confirmation_required" }
|
|
2133
|
+
]
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
if (!args.confirmation) throw new Error("confirmation is required for confirm");
|
|
2137
|
+
const result = await ctx.client.deleteSite(site.siteId, site.credential, {
|
|
2138
|
+
operationId: args.operationId,
|
|
2139
|
+
confirmation: args.confirmation
|
|
2140
|
+
});
|
|
2141
|
+
deleteSiteFile(ctx.projectDir);
|
|
2142
|
+
return structuredToolResult({
|
|
2143
|
+
schemaVersion: 1,
|
|
2144
|
+
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2145
|
+
resultCode: "site_deleted",
|
|
2146
|
+
operationId: args.operationId,
|
|
2147
|
+
summary: "\u7AD9\u70B9\u5DF2\u5220\u9664\uFF0C\u672C\u5730\u7BA1\u7406\u51ED\u8BC1\u6587\u4EF6\u5DF2\u79FB\u9664\u3002",
|
|
2148
|
+
data: { result },
|
|
2149
|
+
nextActions: []
|
|
2150
|
+
});
|
|
2151
|
+
} catch (error) {
|
|
2152
|
+
return toolError(error);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
);
|
|
2156
|
+
server.registerTool(
|
|
2157
|
+
"unbind_domain",
|
|
2158
|
+
{
|
|
2159
|
+
description: "Preview or execute removal of the custom apex/www serving surface while preserving the subscription and permanent Sakupa URL.",
|
|
2160
|
+
inputSchema: {
|
|
2161
|
+
action: z3.enum(["preview", "confirm"]),
|
|
2162
|
+
operationId: z3.string().min(1).optional(),
|
|
2163
|
+
confirmation: unbindConfirmation.optional()
|
|
2164
|
+
},
|
|
2165
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2166
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
|
|
2167
|
+
},
|
|
2168
|
+
async (args) => {
|
|
2169
|
+
try {
|
|
2170
|
+
const site = requireSiteFile(ctx);
|
|
2171
|
+
if (!args.operationId) throw new Error("operationId is required for unbind_domain");
|
|
2172
|
+
if (args.action === "preview") {
|
|
2173
|
+
const preview = await ctx.client.previewUnbindDomain(site.siteId, site.credential, {
|
|
2174
|
+
operationId: args.operationId
|
|
2175
|
+
});
|
|
2176
|
+
return structuredToolResult({
|
|
2177
|
+
schemaVersion: 1,
|
|
2178
|
+
outcome: "waiting_user",
|
|
2179
|
+
resultCode: "unbind_domain_confirmation_required",
|
|
2180
|
+
operationId: args.operationId,
|
|
2181
|
+
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",
|
|
2182
|
+
data: { preview },
|
|
2183
|
+
nextActions: [
|
|
2184
|
+
{ tool: "unbind_domain", allowed: true, reasonCode: "exact_confirmation_required" }
|
|
2185
|
+
]
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
if (!args.confirmation) throw new Error("confirmation is required for confirm");
|
|
2189
|
+
const result = await ctx.client.unbindDomain(site.siteId, site.credential, {
|
|
2190
|
+
operationId: args.operationId,
|
|
2191
|
+
confirmation: args.confirmation
|
|
2192
|
+
});
|
|
2193
|
+
const { boundDomain: _removed, ...remaining } = site;
|
|
2194
|
+
writeSiteFile(ctx.projectDir, remaining);
|
|
2195
|
+
return structuredToolResult({
|
|
2196
|
+
schemaVersion: 1,
|
|
2197
|
+
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2198
|
+
resultCode: "domain_unbound",
|
|
2199
|
+
operationId: args.operationId,
|
|
2200
|
+
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",
|
|
2201
|
+
data: { result },
|
|
2202
|
+
nextActions: [{ tool: "site_status", allowed: true }]
|
|
2203
|
+
});
|
|
2204
|
+
} catch (error) {
|
|
2205
|
+
return toolError(error);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2005
2211
|
// src/server.ts
|
|
2006
2212
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2007
2213
|
|
|
2008
2214
|
// src/tools/billing.ts
|
|
2009
|
-
import { z as
|
|
2010
|
-
var plan =
|
|
2011
|
-
var trigger = z3.enum(["actual_overage", "credential_automation"]);
|
|
2215
|
+
import { z as z4 } from "zod";
|
|
2216
|
+
var plan = z4.enum(["water", "personal", "share", "business"]);
|
|
2012
2217
|
function registerBillingTools(server, ctx) {
|
|
2013
2218
|
server.registerTool(
|
|
2014
2219
|
"list_billing_plans",
|
|
@@ -2035,93 +2240,37 @@ function registerBillingTools(server, ctx) {
|
|
|
2035
2240
|
}
|
|
2036
2241
|
);
|
|
2037
2242
|
server.registerTool(
|
|
2038
|
-
"
|
|
2243
|
+
"change_subscription_plan",
|
|
2039
2244
|
{
|
|
2040
|
-
description: "
|
|
2245
|
+
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.",
|
|
2041
2246
|
inputSchema: {
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
"create_automation_authorization",
|
|
2045
|
-
"disable_automation",
|
|
2046
|
-
"request_authorized_upgrade"
|
|
2047
|
-
]),
|
|
2048
|
-
operationId: z3.string().min(1).describe("Stable idempotency key chosen by the caller."),
|
|
2049
|
-
targetPlan: plan.optional(),
|
|
2050
|
-
maxPlan: plan.optional(),
|
|
2051
|
-
allowedTriggers: z3.array(trigger).min(1).optional(),
|
|
2052
|
-
observedSnapshotId: z3.string().optional(),
|
|
2053
|
-
observedCatalogVersion: z3.string().optional()
|
|
2247
|
+
targetPlan: plan,
|
|
2248
|
+
operationId: z4.string().min(1)
|
|
2054
2249
|
},
|
|
2055
2250
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2056
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
2251
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
2057
2252
|
},
|
|
2058
2253
|
async (args) => {
|
|
2059
2254
|
try {
|
|
2060
2255
|
const site = requireSiteFile(ctx);
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
request = {
|
|
2067
|
-
action: args.action,
|
|
2068
|
-
targetPlan: args.targetPlan,
|
|
2069
|
-
operationId: args.operationId
|
|
2070
|
-
};
|
|
2071
|
-
} else if (args.action === "create_automation_authorization") {
|
|
2072
|
-
if (!args.maxPlan || !args.allowedTriggers) {
|
|
2073
|
-
throw new SakupaError(
|
|
2074
|
-
"invalid_request",
|
|
2075
|
-
"maxPlan and allowedTriggers are required for authorization"
|
|
2076
|
-
);
|
|
2077
|
-
}
|
|
2078
|
-
request = {
|
|
2079
|
-
action: args.action,
|
|
2080
|
-
maxPlan: args.maxPlan,
|
|
2081
|
-
allowedTriggers: args.allowedTriggers,
|
|
2082
|
-
operationId: args.operationId
|
|
2083
|
-
};
|
|
2084
|
-
} else if (args.action === "disable_automation") {
|
|
2085
|
-
request = { action: args.action, operationId: args.operationId };
|
|
2086
|
-
} else {
|
|
2087
|
-
if (!args.targetPlan) {
|
|
2088
|
-
throw new SakupaError(
|
|
2089
|
-
"invalid_request",
|
|
2090
|
-
"targetPlan is required for request_authorized_upgrade"
|
|
2091
|
-
);
|
|
2092
|
-
}
|
|
2093
|
-
request = {
|
|
2094
|
-
action: args.action,
|
|
2095
|
-
operationId: args.operationId,
|
|
2096
|
-
targetPlan: args.targetPlan,
|
|
2097
|
-
trigger: "credential_automation",
|
|
2098
|
-
...args.observedSnapshotId ? { observedSnapshotId: args.observedSnapshotId } : {},
|
|
2099
|
-
...args.observedCatalogVersion ? { observedCatalogVersion: args.observedCatalogVersion } : {}
|
|
2100
|
-
};
|
|
2101
|
-
}
|
|
2102
|
-
const result = await ctx.client.manageSubscription(site.siteId, site.credential, request);
|
|
2103
|
-
const portalUrl = "portalUrl" in result ? result.portalUrl : void 0;
|
|
2104
|
-
const authorizationUrl = "authorizationUrl" in result ? result.authorizationUrl : void 0;
|
|
2105
|
-
const executionOutcome = "outcome" in result ? result.outcome : void 0;
|
|
2106
|
-
const outcome = portalUrl || authorizationUrl ? "waiting_user" : executionOutcome === "pending_provider" ? "pending_provider" : executionOutcome === "blocked" ? "blocked" : "completed";
|
|
2107
|
-
const resultCode = "resultCode" in result ? result.resultCode : portalUrl ? "stripe_plan_change_confirmation_required" : authorizationUrl ? "automation_authorization_required" : args.action === "disable_automation" ? "automation_disabled" : "subscription_action_completed";
|
|
2108
|
-
const url = portalUrl ?? authorizationUrl;
|
|
2256
|
+
const result = await ctx.client.changeSubscriptionPlan(site.credential, {
|
|
2257
|
+
siteId: site.siteId,
|
|
2258
|
+
targetPlan: args.targetPlan,
|
|
2259
|
+
operationId: args.operationId
|
|
2260
|
+
});
|
|
2109
2261
|
return structuredToolResult({
|
|
2110
2262
|
schemaVersion: 1,
|
|
2111
|
-
outcome,
|
|
2112
|
-
resultCode,
|
|
2263
|
+
outcome: "waiting_user",
|
|
2264
|
+
resultCode: "stripe_plan_change_confirmation_required",
|
|
2113
2265
|
operationId: args.operationId,
|
|
2114
|
-
summary:
|
|
2115
|
-
data: {
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
expectedOutcome: portalUrl ? "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u786E\u8BA4\u540E\uFF0C\u7531 webhook \u66F4\u65B0 Sakupa \u72B6\u6001" : "\u7528\u6237\u5728 Sakupa \u77ED\u65F6\u6548\u9875\u9762\u786E\u8BA4\u7CBE\u786E\u81EA\u52A8\u5347\u7EA7\u6388\u6743"
|
|
2123
|
-
}
|
|
2124
|
-
} : {},
|
|
2266
|
+
summary: `\u5DF2\u751F\u6210\u4ECE ${result.currentPlan} \u5230 ${result.targetPlan} \u7684 Stripe \u786E\u8BA4\u94FE\u63A5\uFF1B\u8BA2\u9605\u5C1A\u672A\u53D8\u66F4\u3002`,
|
|
2267
|
+
data: { result },
|
|
2268
|
+
userAction: {
|
|
2269
|
+
type: "open_url",
|
|
2270
|
+
provider: "stripe",
|
|
2271
|
+
url: result.portalUrl,
|
|
2272
|
+
expectedOutcome: "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u786E\u8BA4\u540E\uFF0C\u7531 webhook \u66F4\u65B0 Sakupa \u8BA2\u9605\u72B6\u6001"
|
|
2273
|
+
},
|
|
2125
2274
|
nextActions: [{ tool: "billing_status", allowed: true }]
|
|
2126
2275
|
});
|
|
2127
2276
|
} catch (error) {
|
|
@@ -2145,13 +2294,13 @@ Workflow:
|
|
|
2145
2294
|
3. To make the site PERMANENT, subscribe it to a monthly hosting plan (subscribe_site ->
|
|
2146
2295
|
Stripe-hosted checkout; water/personal/share/business). Paying makes the
|
|
2147
2296
|
{shortId}.sakupa.com URL permanent \u2014 that is what payment buys. Usage over the chosen plan
|
|
2148
|
-
shows an over-limit notice by default.
|
|
2149
|
-
|
|
2297
|
+
shows an over-limit notice by default. An external AI may periodically query usage and
|
|
2298
|
+
recommend a plan, but Sakupa never changes a subscription automatically.
|
|
2150
2299
|
4. Optionally bind a custom domain to the subscribed site (bind_domain): an included extra
|
|
2151
2300
|
serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
|
|
2152
2301
|
first verified request wins; unverified requests expire after 72 hours. billing_status,
|
|
2153
|
-
|
|
2154
|
-
changes are confirmed only on Stripe
|
|
2302
|
+
billing_status, change_subscription_plan, manage_billing and recover_domain_site manage the paid
|
|
2303
|
+
lifecycle. Plan changes are confirmed only on Stripe and synchronized by Stripe webhook.
|
|
2155
2304
|
A cancellation keeps the site paid through the current period. Sakupa reverts it to a free
|
|
2156
2305
|
24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
|
|
2157
2306
|
|
|
@@ -2165,7 +2314,9 @@ Safety boundaries:
|
|
|
2165
2314
|
Stripe's public no-code portal login, where the customer verifies the checkout email with a
|
|
2166
2315
|
Stripe one-time passcode; it never restores site authority.`;
|
|
2167
2316
|
function createSakupaMcpServer(opts) {
|
|
2168
|
-
const client = opts.client ?? new HttpApiClient(
|
|
2317
|
+
const client = opts.client ?? new HttpApiClient(
|
|
2318
|
+
new FetchTransport(opts.apiBaseUrl, { testAccessToken: opts.testAccessToken })
|
|
2319
|
+
);
|
|
2169
2320
|
const server = new McpServer(
|
|
2170
2321
|
{ name: "sakupa", version: MCP_VERSION },
|
|
2171
2322
|
{ instructions: INSTRUCTIONS }
|
|
@@ -2180,6 +2331,11 @@ function createSakupaMcpServer(opts) {
|
|
|
2180
2331
|
projectDir: opts.projectDir,
|
|
2181
2332
|
apiBaseUrl: opts.apiBaseUrl
|
|
2182
2333
|
});
|
|
2334
|
+
registerLifecycleTools(server, {
|
|
2335
|
+
client,
|
|
2336
|
+
projectDir: opts.projectDir,
|
|
2337
|
+
apiBaseUrl: opts.apiBaseUrl
|
|
2338
|
+
});
|
|
2183
2339
|
return server;
|
|
2184
2340
|
}
|
|
2185
2341
|
export {
|
|
@@ -2187,10 +2343,13 @@ export {
|
|
|
2187
2343
|
FetchTransport,
|
|
2188
2344
|
HttpApiClient,
|
|
2189
2345
|
MCP_VERSION,
|
|
2346
|
+
TEST_API_BASE_URL,
|
|
2190
2347
|
analyzeProject,
|
|
2191
2348
|
createSakupaMcpServer,
|
|
2192
2349
|
deleteSiteFile,
|
|
2350
|
+
loadMcpRuntimeConfig,
|
|
2193
2351
|
readSiteFile,
|
|
2352
|
+
registerLifecycleTools,
|
|
2194
2353
|
registerTools,
|
|
2195
2354
|
requireSiteFile,
|
|
2196
2355
|
siteFilePath,
|