@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.
Files changed (3) hide show
  1. package/dist/bin.js +268 -108
  2. package/dist/index.js +269 -110
  3. 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.6.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
- await this.call("DELETE", `/v1/sites/${encodeURIComponent(siteId)}`, { credential });
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 manageSubscription(siteId, credential, req) {
664
+ async changeSubscriptionPlan(credential, req) {
633
665
  return this.call(
634
666
  "POST",
635
- `/v1/sites/${encodeURIComponent(siteId)}/billing/manage`,
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 = 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";
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: { errorCode, retryable },
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
- "manage_subscription",
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: "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.",
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: 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()
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
- 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,
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
- } 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,
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
- } 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,
2182
+ });
2183
+ return structuredToolResult({
2184
+ schemaVersion: 1,
2185
+ outcome: "waiting_user",
2186
+ resultCode: "unbind_domain_confirmation_required",
2044
2187
  operationId: args.operationId,
2045
- targetPlan: args.targetPlan,
2046
- trigger: "credential_automation",
2047
- ...args.observedSnapshotId ? { observedSnapshotId: args.observedSnapshotId } : {},
2048
- ...args.observedCatalogVersion ? { observedCatalogVersion: args.observedCatalogVersion } : {}
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
- 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;
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: 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 }]
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
- constructor(baseUrl) {
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: target.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. Automatic upgrades require a separate, bounded,
2151
- one-time Sakupa authorization and can never exceed the user-approved plan cap.
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
- 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.
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(new FetchTransport(opts.apiBaseUrl));
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 apiBaseUrl = process.env["SAKUPA_API_URL"] ?? DEFAULT_API_BASE_URL;
2191
- const projectDir = process.env["SAKUPA_PROJECT_DIR"] ?? process.cwd();
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.6.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
- constructor(baseUrl) {
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: target.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
- await this.call("DELETE", `/v1/sites/${encodeURIComponent(siteId)}`, { credential });
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 manageSubscription(siteId, credential, req) {
732
+ async changeSubscriptionPlan(credential, req) {
681
733
  return this.call(
682
734
  "POST",
683
- `/v1/sites/${encodeURIComponent(siteId)}/billing/manage`,
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 = false;
1250
- const safeSummary = errorCode === "not_found" ? "\u6240\u9700\u7684\u672C\u5730\u9879\u76EE\u7ED1\u5B9A\u6216\u8D44\u6E90\u4E0D\u53EF\u7528\uFF1B\u5982\u679C\u672C\u5730\u6CA1\u6709 .sakupa/site.json\uFF0C\u8BF7\u5148\u8FD0\u884C deploy_site first\u3002" : errorCode === "unauthorized" ? "\u5F53\u524D\u64CD\u4F5C\u672A\u901A\u8FC7\u7AD9\u70B9\u6743\u9650\u6821\u9A8C\u3002" : errorCode === "invalid_request" || errorCode === "validation_failed" ? "\u8BF7\u6C42\u53C2\u6570\u6216\u672C\u5730\u9879\u76EE\u68C0\u67E5\u672A\u901A\u8FC7\u3002" : errorCode === "state_conflict" ? "\u8D44\u6E90\u72B6\u6001\u5DF2\u7ECF\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u67E5\u8BE2\u72B6\u6001\u540E\u518D\u51B3\u5B9A\u4E0B\u4E00\u6B65\u3002" : retryable ? "\u5916\u90E8\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\u6216\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" : "\u64CD\u4F5C\u5931\u8D25\uFF1B\u672A\u8FD4\u56DE\u670D\u52A1\u7AEF\u5185\u90E8\u8BE6\u60C5\u3002";
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: { errorCode, retryable },
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 z3 } from "zod";
2010
- var plan = z3.enum(["water", "personal", "share", "business"]);
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
- "manage_subscription",
2243
+ "change_subscription_plan",
2039
2244
  {
2040
- description: "Manage this site subscription through explicit actions. open_plan_change returns a Stripe-hosted confirmation URL. create_automation_authorization returns a short-lived Sakupa authorization URL binding the exact cap and triggers. disable_automation is immediate and owner-authorized. request_authorized_upgrade executes only under an existing exact grant, fresh usage snapshot, catalog version and server-side safety gates.",
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
- action: z3.enum([
2043
- "open_plan_change",
2044
- "create_automation_authorization",
2045
- "disable_automation",
2046
- "request_authorized_upgrade"
2047
- ]),
2048
- operationId: z3.string().min(1).describe("Stable idempotency key chosen by the caller."),
2049
- targetPlan: plan.optional(),
2050
- maxPlan: plan.optional(),
2051
- allowedTriggers: z3.array(trigger).min(1).optional(),
2052
- observedSnapshotId: z3.string().optional(),
2053
- observedCatalogVersion: z3.string().optional()
2247
+ targetPlan: plan,
2248
+ operationId: z4.string().min(1)
2054
2249
  },
2055
2250
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2056
- annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
2251
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
2057
2252
  },
2058
2253
  async (args) => {
2059
2254
  try {
2060
2255
  const site = requireSiteFile(ctx);
2061
- let request;
2062
- if (args.action === "open_plan_change") {
2063
- if (!args.targetPlan) {
2064
- throw new SakupaError("invalid_request", "targetPlan is required for open_plan_change");
2065
- }
2066
- request = {
2067
- action: args.action,
2068
- targetPlan: args.targetPlan,
2069
- operationId: args.operationId
2070
- };
2071
- } else if (args.action === "create_automation_authorization") {
2072
- if (!args.maxPlan || !args.allowedTriggers) {
2073
- throw new SakupaError(
2074
- "invalid_request",
2075
- "maxPlan and allowedTriggers are required for authorization"
2076
- );
2077
- }
2078
- request = {
2079
- action: args.action,
2080
- maxPlan: args.maxPlan,
2081
- allowedTriggers: args.allowedTriggers,
2082
- operationId: args.operationId
2083
- };
2084
- } else if (args.action === "disable_automation") {
2085
- request = { action: args.action, operationId: args.operationId };
2086
- } else {
2087
- if (!args.targetPlan) {
2088
- throw new SakupaError(
2089
- "invalid_request",
2090
- "targetPlan is required for request_authorized_upgrade"
2091
- );
2092
- }
2093
- request = {
2094
- action: args.action,
2095
- operationId: args.operationId,
2096
- targetPlan: args.targetPlan,
2097
- trigger: "credential_automation",
2098
- ...args.observedSnapshotId ? { observedSnapshotId: args.observedSnapshotId } : {},
2099
- ...args.observedCatalogVersion ? { observedCatalogVersion: args.observedCatalogVersion } : {}
2100
- };
2101
- }
2102
- const result = await ctx.client.manageSubscription(site.siteId, site.credential, request);
2103
- const portalUrl = "portalUrl" in result ? result.portalUrl : void 0;
2104
- const authorizationUrl = "authorizationUrl" in result ? result.authorizationUrl : void 0;
2105
- const executionOutcome = "outcome" in result ? result.outcome : void 0;
2106
- const outcome = portalUrl || authorizationUrl ? "waiting_user" : executionOutcome === "pending_provider" ? "pending_provider" : executionOutcome === "blocked" ? "blocked" : "completed";
2107
- const resultCode = "resultCode" in result ? result.resultCode : portalUrl ? "stripe_plan_change_confirmation_required" : authorizationUrl ? "automation_authorization_required" : args.action === "disable_automation" ? "automation_disabled" : "subscription_action_completed";
2108
- const url = portalUrl ?? authorizationUrl;
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: url ? "\u5DF2\u521B\u5EFA\u77ED\u65F6\u6548\u6258\u7BA1\u786E\u8BA4\u94FE\u63A5\uFF1B\u5F53\u524D\u8BA2\u9605\u5C1A\u672A\u56E0\u521B\u5EFA\u94FE\u63A5\u800C\u6539\u53D8\u3002" : `\u8BA2\u9605\u52A8\u4F5C\u7ED3\u679C\uFF1A${resultCode}`,
2115
- data: { action: args.action, result },
2116
- ...url ? {
2117
- userAction: {
2118
- type: "open_url",
2119
- provider: portalUrl ? "stripe" : "sakupa",
2120
- url,
2121
- ..."expiresAt" in result && typeof result.expiresAt === "string" ? { expiresAt: result.expiresAt } : {},
2122
- expectedOutcome: portalUrl ? "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u786E\u8BA4\u540E\uFF0C\u7531 webhook \u66F4\u65B0 Sakupa \u72B6\u6001" : "\u7528\u6237\u5728 Sakupa \u77ED\u65F6\u6548\u9875\u9762\u786E\u8BA4\u7CBE\u786E\u81EA\u52A8\u5347\u7EA7\u6388\u6743"
2123
- }
2124
- } : {},
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. Automatic upgrades require a separate, bounded,
2149
- one-time Sakupa authorization and can never exceed the user-approved plan cap.
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
- manage_subscription, manage_billing and recover_domain_site manage the paid lifecycle. Manual plan
2154
- changes are confirmed only on Stripe Customer Portal and synchronized by Stripe webhook.
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(new FetchTransport(opts.apiBaseUrl));
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakupa/mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Sakupa MCP server: publish AI-made static sites from your AI tool. AI-made pages, live in seconds.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",