@sakupa/mcp 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/bin.js +638 -170
  2. package/dist/index.js +643 -175
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -402,7 +402,7 @@ function isFreeSiteAllowanceNetworkReference(value) {
402
402
  }
403
403
 
404
404
  // ../core/dist/domain/version.js
405
- var SAKUPA_MCP_VERSION = "1.1.0";
405
+ var SAKUPA_MCP_VERSION = "1.3.0";
406
406
 
407
407
  // ../core/dist/domain/errors.js
408
408
  var HTTP_STATUS = {
@@ -738,13 +738,15 @@ function previewHostPatternFor(apiBaseUrl) {
738
738
  function loadMcpRuntimeConfig(env = process.env) {
739
739
  const apiBaseUrl = (env["SAKUPA_API_URL"] ?? env["SAKUPA_API_BASE_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
740
740
  const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
741
+ const projectRoot = env["SAKUPA_PROJECT_ROOT"]?.trim() ?? "";
742
+ const projectRootConfig = projectRoot.length > 0 ? { projectRoot } : {};
741
743
  if (apiBaseUrl === TEST_API_BASE_URL) {
742
744
  if (testAccessToken.length === 0) {
743
745
  throw new Error(
744
746
  "The Sakupa Test API requires SAKUPA_TEST_ACCESS_TOKEN. Anonymous Test access is disabled."
745
747
  );
746
748
  }
747
- return { apiBaseUrl, testAccessToken };
749
+ return { apiBaseUrl, testAccessToken, ...projectRootConfig };
748
750
  }
749
751
  if (apiBaseUrl !== PRODUCTION_API_BASE_URL) {
750
752
  throw new Error(
@@ -756,7 +758,7 @@ function loadMcpRuntimeConfig(env = process.env) {
756
758
  `SAKUPA_TEST_ACCESS_TOKEN may only be used with ${TEST_API_BASE_URL}. Remove it before connecting to any other API.`
757
759
  );
758
760
  }
759
- return { apiBaseUrl };
761
+ return { apiBaseUrl, ...projectRootConfig };
760
762
  }
761
763
  function environmentFor(apiBaseUrl) {
762
764
  if (apiBaseUrl === TEST_API_BASE_URL) return "test";
@@ -768,8 +770,10 @@ function environmentFor(apiBaseUrl) {
768
770
  import {
769
771
  CLIENT_CAPABILITIES_META_KEY,
770
772
  McpServer,
773
+ createRequestStateCodec,
771
774
  inputResponse
772
775
  } from "@modelcontextprotocol/server";
776
+ import { randomBytes as randomBytes2 } from "node:crypto";
773
777
 
774
778
  // src/api-client.ts
775
779
  var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
@@ -2957,7 +2961,7 @@ import {
2957
2961
 
2958
2962
  // src/project-binding.ts
2959
2963
  import { fileURLToPath } from "node:url";
2960
- import { resolve as resolve4 } from "node:path";
2964
+ import { isAbsolute as isAbsolute4, resolve as resolve4 } from "node:path";
2961
2965
  var MCP_ROOTS_TIMEOUT_MS = 5e3;
2962
2966
  var McpRootsPending = class extends Error {
2963
2967
  constructor() {
@@ -2974,10 +2978,11 @@ var ProjectBindingError = class extends Error {
2974
2978
  }
2975
2979
  };
2976
2980
  var ProjectBindingResolver = class {
2977
- constructor(processCwd, rootsProvider, rootsTimeoutMs = MCP_ROOTS_TIMEOUT_MS) {
2981
+ constructor(processCwd, rootsProvider, rootsTimeoutMs = MCP_ROOTS_TIMEOUT_MS, configuredRoot) {
2978
2982
  this.processCwd = processCwd;
2979
2983
  this.rootsProvider = rootsProvider;
2980
2984
  this.rootsTimeoutMs = rootsTimeoutMs;
2985
+ this.configuredRoot = configuredRoot;
2981
2986
  }
2982
2987
  bound;
2983
2988
  boundState;
@@ -3016,28 +3021,41 @@ var ProjectBindingResolver = class {
3016
3021
  throw new ProjectBindingError(inspection.diagnostics);
3017
3022
  }
3018
3023
  const initialized = initializeProject(inspection.initializableRoot);
3019
- this.bound = { ...initialized, bindingSource: "mcp_root" };
3024
+ this.bound = {
3025
+ ...initialized,
3026
+ bindingSource: inspection.initializableSource ?? "mcp_root"
3027
+ };
3020
3028
  this.boundState = boundDiagnostics(
3021
3029
  this.processCwd,
3022
3030
  this.bound,
3023
- { supported: true, roots: [] },
3024
- inspection.diagnostics.rootCandidates
3031
+ { supported: inspection.diagnostics.mcpRootsSupported, roots: [] },
3032
+ inspection.diagnostics.rootCandidates,
3033
+ inspection.diagnostics.configuredProjectRoot
3025
3034
  );
3026
3035
  return this.bound;
3027
3036
  }
3028
3037
  async inspect(forInitialization = false, call) {
3029
3038
  const snapshot = await safeRootsSnapshot(this.rootsProvider, this.rootsTimeoutMs, call);
3030
3039
  const rootCandidates = snapshot.roots.map(inspectRoot);
3040
+ const configured = this.configuredRoot === void 0 ? void 0 : inspectConfiguredRoot(this.configuredRoot);
3041
+ const diagnose = (code, guidance) => diagnostic(code, snapshot, this.processCwd, rootCandidates, guidance, configured);
3042
+ const bound = (selected) => ({
3043
+ selected,
3044
+ diagnostics: boundDiagnostics(
3045
+ this.processCwd,
3046
+ selected,
3047
+ snapshot,
3048
+ rootCandidates,
3049
+ configured
3050
+ )
3051
+ });
3031
3052
  const initializedRoots = rootCandidates.filter(
3032
3053
  (candidate) => candidate.initialized && candidate.path !== void 0
3033
3054
  );
3034
3055
  if (snapshot.supported && snapshot.error) {
3035
3056
  return {
3036
- diagnostics: diagnostic(
3057
+ diagnostics: diagnose(
3037
3058
  "roots_request_failed",
3038
- snapshot,
3039
- this.processCwd,
3040
- rootCandidates,
3041
3059
  "The IDE advertised MCP Roots, but the Roots request failed. Retry help after the IDE finishes loading the workspace. If it persists, restart the MCP connection; do not initialize or deploy from the IDE installation directory."
3042
3060
  )
3043
3061
  };
@@ -3046,19 +3064,12 @@ var ProjectBindingResolver = class {
3046
3064
  const initializedRoot = initializedRoots[0];
3047
3065
  if (!initializedRoot) throw new Error("initialized Root disappeared during resolution");
3048
3066
  const project = resolveLockedProjectRoot(initializedRoot.path);
3049
- const selected = { ...project, bindingSource: "mcp_root" };
3050
- return {
3051
- selected,
3052
- diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
3053
- };
3067
+ return bound({ ...project, bindingSource: "mcp_root" });
3054
3068
  }
3055
3069
  if (initializedRoots.length > 1) {
3056
3070
  return {
3057
- diagnostics: diagnostic(
3071
+ diagnostics: diagnose(
3058
3072
  "multiple_initialized_roots",
3059
- snapshot,
3060
- this.processCwd,
3061
- rootCandidates,
3062
3073
  "More than one IDE workspace Root is already initialized for Sakupa. Close the unrelated workspaces and retry help; Sakupa will not guess which site to manage."
3063
3074
  )
3064
3075
  };
@@ -3072,22 +3083,17 @@ var ProjectBindingResolver = class {
3072
3083
  if (!validRoot) throw new Error("workspace Root disappeared during initialization");
3073
3084
  return {
3074
3085
  initializableRoot: validRoot.path,
3075
- diagnostics: diagnostic(
3086
+ initializableSource: "mcp_root",
3087
+ diagnostics: diagnose(
3076
3088
  "workspace_not_initialized",
3077
- snapshot,
3078
- this.processCwd,
3079
- rootCandidates,
3080
3089
  `The active MCP workspace ${validRoot.path} is ready to initialize.`
3081
3090
  )
3082
3091
  };
3083
3092
  }
3084
3093
  if (validRoots.length > 1) {
3085
3094
  return {
3086
- diagnostics: diagnostic(
3095
+ diagnostics: diagnose(
3087
3096
  "multiple_uninitialized_roots",
3088
- snapshot,
3089
- this.processCwd,
3090
- rootCandidates,
3091
3097
  "The IDE exposes multiple uninitialized workspace Roots. Open only the intended project before calling init; Sakupa will not choose a directory for the user."
3092
3098
  )
3093
3099
  };
@@ -3097,54 +3103,59 @@ var ProjectBindingResolver = class {
3097
3103
  const validRoot = validRoots[0];
3098
3104
  if (!validRoot) throw new Error("workspace Root disappeared during diagnosis");
3099
3105
  return {
3100
- diagnostics: diagnostic(
3106
+ diagnostics: diagnose(
3101
3107
  "workspace_not_initialized",
3102
- snapshot,
3103
- this.processCwd,
3104
- rootCandidates,
3105
3108
  `The IDE workspace ${validRoot.path} is not initialized. Call init with no path arguments; it will create .sakupa directly in that workspace Root.`
3106
3109
  )
3107
3110
  };
3108
3111
  }
3109
3112
  if (snapshot.supported && validRoots.length > 1) {
3110
3113
  return {
3111
- diagnostics: diagnostic(
3114
+ diagnostics: diagnose(
3112
3115
  "multiple_uninitialized_roots",
3113
- snapshot,
3114
- this.processCwd,
3115
- rootCandidates,
3116
3116
  "The IDE exposes multiple uninitialized workspace Roots. Open only the intended project, then call init. Sakupa will not guess a project directory."
3117
3117
  )
3118
3118
  };
3119
3119
  }
3120
+ if (configured) {
3121
+ if (configured.problem !== void 0 || configured.path === void 0) {
3122
+ return {
3123
+ diagnostics: diagnose(
3124
+ "invalid_configured_root",
3125
+ `SAKUPA_PROJECT_ROOT is set to ${configured.configured} but it is not usable: ${configured.problem ?? "unknown problem"}. Fix the MCP server configuration (an absolute path to an existing project directory) and retry help; Sakupa will not fall back to another directory.`
3126
+ )
3127
+ };
3128
+ }
3129
+ if (configured.initialized) {
3130
+ const project = resolveLockedProjectRoot(configured.path);
3131
+ return bound({ ...project, bindingSource: "configured_root" });
3132
+ }
3133
+ return {
3134
+ ...forInitialization ? { initializableRoot: configured.path, initializableSource: "configured_root" } : {},
3135
+ diagnostics: diagnose(
3136
+ "workspace_not_initialized",
3137
+ `The configured project root ${configured.path} (SAKUPA_PROJECT_ROOT) is not initialized. Call init with no path arguments; it will create .sakupa directly there.`
3138
+ )
3139
+ };
3140
+ }
3120
3141
  if (snapshot.supported) {
3121
3142
  return {
3122
- diagnostics: diagnostic(
3143
+ diagnostics: diagnose(
3123
3144
  "workspace_not_initialized",
3124
- snapshot,
3125
- this.processCwd,
3126
- rootCandidates,
3127
3145
  "The IDE did not expose one usable file workspace Root. Open exactly one local project workspace, then retry help before calling init or deploy."
3128
3146
  )
3129
3147
  };
3130
3148
  }
3131
3149
  try {
3132
3150
  const cwdProject = resolveLockedProjectRoot(this.processCwd);
3133
- const selected = { ...cwdProject, bindingSource: "process_cwd" };
3134
- return {
3135
- selected,
3136
- diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
3137
- };
3151
+ return bound({ ...cwdProject, bindingSource: "process_cwd" });
3138
3152
  } catch {
3139
3153
  }
3140
3154
  const cwdProblem = inspectDirectory(this.processCwd);
3141
3155
  return {
3142
- diagnostics: diagnostic(
3156
+ diagnostics: diagnose(
3143
3157
  cwdProblem.problem ? "invalid_process_cwd" : "process_cwd_is_not_workspace",
3144
- snapshot,
3145
- this.processCwd,
3146
- rootCandidates,
3147
- "This IDE did not provide MCP Roots and the MCP process cwd is not an initialized project. Do not write into the IDE installation directory. Run help from the intended project context. If help confirms the missing-Roots diagnosis, the AI may run `npx -y @sakupa/mcp@latest init` with no path arguments from that directory. Never ask the user to run it."
3158
+ "This IDE did not provide MCP Roots, SAKUPA_PROJECT_ROOT is not configured, and the MCP process cwd is not an initialized project. Do not write into the IDE installation directory. Run help from the intended project context. If help confirms the missing-Roots diagnosis, the AI may run `npx -y @sakupa/mcp@latest init` with no path arguments from that directory, or the MCP server configuration may set SAKUPA_PROJECT_ROOT to the absolute project path. Never ask the user to run it."
3148
3159
  )
3149
3160
  };
3150
3161
  }
@@ -3157,11 +3168,7 @@ function fileRootUriToPath(uri, windows = process.platform === "win32") {
3157
3168
  async function safeRootsSnapshot(provider, timeoutMs = MCP_ROOTS_TIMEOUT_MS, call) {
3158
3169
  if (!provider) return { supported: false, roots: [] };
3159
3170
  try {
3160
- return await withOperationTimeout(
3161
- "MCP Roots request",
3162
- timeoutMs,
3163
- () => provider(call)
3164
- );
3171
+ return await withOperationTimeout("MCP Roots request", timeoutMs, () => provider(call));
3165
3172
  } catch (error) {
3166
3173
  if (error instanceof McpRootsPending) throw error;
3167
3174
  return {
@@ -3191,6 +3198,31 @@ function inspectRoot(root) {
3191
3198
  };
3192
3199
  }
3193
3200
  }
3201
+ function inspectConfiguredRoot(configured) {
3202
+ if (!isAbsolute4(configured)) {
3203
+ return {
3204
+ configured,
3205
+ initialized: false,
3206
+ problem: "the value must be an absolute path"
3207
+ };
3208
+ }
3209
+ try {
3210
+ const path = canonicalProjectDirectory(configured);
3211
+ const marker = loadProjectMarker(path);
3212
+ return {
3213
+ configured,
3214
+ path,
3215
+ initialized: marker.kind === "ok",
3216
+ ...marker.kind === "corrupted" ? { problem: marker.problem } : {}
3217
+ };
3218
+ } catch (error) {
3219
+ return {
3220
+ configured,
3221
+ initialized: false,
3222
+ problem: error instanceof Error ? error.message : String(error)
3223
+ };
3224
+ }
3225
+ }
3194
3226
  function inspectDirectory(path) {
3195
3227
  try {
3196
3228
  return { path: canonicalProjectDirectory(resolve4(path)) };
@@ -3198,22 +3230,24 @@ function inspectDirectory(path) {
3198
3230
  return { problem: error instanceof Error ? error.message : String(error) };
3199
3231
  }
3200
3232
  }
3201
- function diagnostic(diagnosisCode, snapshot, processCwd, rootCandidates, guidance) {
3233
+ function diagnostic(diagnosisCode, snapshot, processCwd, rootCandidates, guidance, configured) {
3202
3234
  return {
3203
3235
  diagnosisCode,
3204
3236
  mcpRootsSupported: snapshot.supported,
3205
3237
  processCwd,
3206
3238
  rootCandidates,
3239
+ ...configured ? { configuredProjectRoot: configured } : {},
3207
3240
  guidance,
3208
3241
  reportRecommended: false
3209
3242
  };
3210
3243
  }
3211
- function boundDiagnostics(processCwd, selected, snapshot = { supported: false, roots: [] }, rootCandidates = []) {
3244
+ function boundDiagnostics(processCwd, selected, snapshot = { supported: false, roots: [] }, rootCandidates = [], configured) {
3212
3245
  return {
3213
3246
  diagnosisCode: "project_bound",
3214
3247
  mcpRootsSupported: snapshot.supported,
3215
3248
  processCwd,
3216
3249
  rootCandidates,
3250
+ ...configured ? { configuredProjectRoot: configured } : {},
3217
3251
  selectedProjectDir: selected.projectDir,
3218
3252
  bindingSource: selected.bindingSource,
3219
3253
  guidance: `Sakupa is locked to ${selected.projectDir} from ${selected.bindingSource}.`,
@@ -3379,10 +3413,51 @@ function structuredToolResult(envelope) {
3379
3413
  return {
3380
3414
  content: [{ type: "text", text: `${envelope.summary}
3381
3415
 
3416
+ ---
3382
3417
  ${presentationFallback}` }],
3383
3418
  structuredContent: structuredEnvelope
3384
3419
  };
3385
3420
  }
3421
+ var SUMMARY_HEADINGS = {
3422
+ steps: "Do this yourself",
3423
+ notes: "Notes",
3424
+ next: "Next"
3425
+ };
3426
+ function tableCell(value) {
3427
+ return String(value).replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
3428
+ }
3429
+ function summaryMarkdown(sections) {
3430
+ const blocks = [`## ${sections.title.trim()}`];
3431
+ if (sections.lead?.trim()) blocks.push(sections.lead.trim());
3432
+ const facts = (sections.facts ?? []).filter(
3433
+ (row) => row[1] !== void 0 && row[1] !== ""
3434
+ );
3435
+ if (facts.length > 0) {
3436
+ blocks.push(
3437
+ [
3438
+ "| Item | Value |",
3439
+ "|---|---|",
3440
+ ...facts.map(([k, v]) => `| ${tableCell(k)} | ${tableCell(v)} |`)
3441
+ ].join("\n")
3442
+ );
3443
+ }
3444
+ if (sections.steps?.length) {
3445
+ blocks.push(
3446
+ `### ${SUMMARY_HEADINGS.steps}
3447
+ ${sections.steps.map((step, i) => `${i + 1}. ${step}`).join("\n")}`
3448
+ );
3449
+ }
3450
+ if (sections.notes?.length) {
3451
+ blocks.push(`### ${SUMMARY_HEADINGS.notes}
3452
+ ${sections.notes.map((n) => `- ${n}`).join("\n")}`);
3453
+ }
3454
+ if (sections.next?.length) {
3455
+ blocks.push(`### ${SUMMARY_HEADINGS.next}
3456
+ ${sections.next.map((n) => `- ${n}`).join("\n")}`);
3457
+ }
3458
+ if (sections.raw?.trim()) blocks.push(sections.raw.trim());
3459
+ return blocks.join("\n\n");
3460
+ }
3386
3461
  function timestampForAgent(exactTimestamp) {
3387
3462
  return timestampForAgentInZone(exactTimestamp, clientRuntimeTimeZone());
3388
3463
  }
@@ -3432,7 +3507,12 @@ function resolverFor(ctx) {
3432
3507
  if (ctx.projectBinding) return ctx.projectBinding;
3433
3508
  let resolver = fallbackResolvers.get(ctx);
3434
3509
  if (!resolver) {
3435
- resolver = new ProjectBindingResolver(ctx.projectDir, ctx.rootsProvider);
3510
+ resolver = new ProjectBindingResolver(
3511
+ ctx.projectDir,
3512
+ ctx.rootsProvider,
3513
+ MCP_ROOTS_TIMEOUT_MS,
3514
+ ctx.configuredProjectRoot
3515
+ );
3436
3516
  fallbackResolvers.set(ctx, resolver);
3437
3517
  }
3438
3518
  return resolver;
@@ -3571,8 +3651,14 @@ function toolError(e) {
3571
3651
  const serverGuidance = isSakupaError(e) && errorCode !== "internal" && errorCode !== "unauthorized" && errorCode !== "upgrade_required" && e.message.trim().length > 0 ? e.message : void 0;
3572
3652
  const safeSummary = timeoutSummary ?? (e instanceof LocalGuidanceError ? e.message : errorCode === "upgrade_required" ? `This Sakupa MCP client is v${MCP_VERSION}, older than the server's minimum supported version${minimumVersion !== void 0 ? ` (v${minimumVersion})` : ""}, so the server refused the call. To fix it: ask the user to fully restart their MCP client session \u2014 "npx -y @sakupa/mcp@latest" setups fetch the current version on restart (run "npx clear-npx-cache" first if the old version persists); global installs need "npm install -g @sakupa/mcp@latest". After the restart, retry this exact tool call.` : errorCode === "unauthorized" ? UNAUTHORIZED_SUMMARY : serverGuidance ?? (retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : opaqueUnclassified ? "This failed with an error Sakupa could not classify, and retrying the same call will not help. Run help with the failed tool and error code first; only use report if help explicitly recommends it." : "The operation failed; no server-internal details are exposed."));
3573
3653
  const customerMeaning = timedOut ? timeoutRetrySafe ? "Sakupa did not receive this read result before the deadline; no automatic retry occurred." : "Sakupa did not receive a final result before the deadline, so the AI must check current state before attempting another write." : errorCode === "unauthorized" ? "The cloud site is still intact, but this project no longer has a valid management credential for it." : errorCode === "upgrade_required" ? "The installed Sakupa MCP version is too old for the current API and must be refreshed before retrying." : errorCode === "payment_required" ? "This action needs an active subscription or a payment issue must be resolved first." : errorCode === "rate_limited" ? "Sakupa temporarily refused this operation because a usage or frequency limit was reached." : errorCode === "not_found" ? "The requested Sakupa site, project binding, or operation could not be found." : errorCode === "forbidden" ? "Sakupa refused this operation because the current authority or site state does not allow it." : errorCode === "conflict" || errorCode === "state_conflict" || errorCode === "confirmation_required" ? "Sakupa safely stopped because the site, billing state, or required confirmation no longer matches." : errorCode === "invalid_request" || errorCode === "validation_failed" ? "Sakupa could not complete the operation because required input or current state was invalid." : retryable ? "A temporary Sakupa dependency problem prevented completion." : "Sakupa did not complete the operation; use the retained diagnostics to determine the safe next step.";
3574
- const userFacingSummary = `Customer meaning: ${customerMeaning}
3575
- Technical context for the AI: ${safeSummary}`;
3654
+ const userFacingSummary = summaryMarkdown({
3655
+ title: `Sakupa could not complete this operation (${errorCode})`,
3656
+ lead: `Customer meaning: ${customerMeaning}`,
3657
+ notes: [`Technical context for the AI: ${safeSummary}`],
3658
+ next: [
3659
+ '`help` with topic "diagnose", the failed tool name and this error code \u2014 before any retry, support request or report'
3660
+ ]
3661
+ });
3576
3662
  const result = structuredToolResult({
3577
3663
  schemaVersion: 1,
3578
3664
  outcome: "failed",
@@ -3601,6 +3687,7 @@ Technical context for the AI: ${safeSummary}`;
3601
3687
  }
3602
3688
 
3603
3689
  // src/tools/decision.ts
3690
+ import { acceptedContent, inputRequired as inputRequired2 } from "@modelcontextprotocol/server";
3604
3691
  var DECISION_PRESENTATION_POLICY = {
3605
3692
  translateFields: [
3606
3693
  "decision.prompt",
@@ -3693,11 +3780,14 @@ function buildDecisionContract(prompt, options) {
3693
3780
  function formatDecisionFallback(decision) {
3694
3781
  const options = decision.options.map((option, index) => {
3695
3782
  const consequences = option.consequences.length === 0 ? "" : `
3696
- Consequences: ${option.consequences.join(" ")}`;
3783
+ - Consequences: ${option.consequences.join(" ")}`;
3697
3784
  let exactAction;
3698
3785
  switch (option.nextAction.type) {
3699
3786
  case "call_tool":
3700
- exactAction = `If the user selects this option, call ${option.nextAction.tool} with these exact arguments: ${JSON.stringify(option.nextAction.arguments)}.`;
3787
+ exactAction = `If the user selects this option, call \`${option.nextAction.tool}\` with these exact arguments:
3788
+ \`\`\`json
3789
+ ${JSON.stringify(option.nextAction.arguments)}
3790
+ \`\`\``;
3701
3791
  break;
3702
3792
  case "open_url":
3703
3793
  exactAction = `If the user selects this option, present this exact URL: ${option.nextAction.url}.`;
@@ -3706,11 +3796,13 @@ function formatDecisionFallback(decision) {
3706
3796
  exactAction = "If the user selects this option, call no tool and make no change.";
3707
3797
  break;
3708
3798
  }
3709
- return `${index + 1}. [${option.id}] ${option.label}
3799
+ return `${index + 1}. [${option.id}] **${option.label}**
3710
3800
  ${option.description}${consequences}
3711
3801
  ${exactAction}`;
3712
3802
  });
3713
- return `USER DECISION REQUIRED: ${decision.prompt}
3803
+ return `### USER DECISION REQUIRED
3804
+ ${decision.prompt}
3805
+
3714
3806
  No option is selected by default. Present every option to the user, do not choose on their behalf, and never reconstruct or guess tool arguments.
3715
3807
 
3716
3808
  ` + options.join("\n\n");
@@ -3790,6 +3882,76 @@ function noActionDecisionOption(input) {
3790
3882
  nextAction: { type: "none" }
3791
3883
  };
3792
3884
  }
3885
+ var DECISION_INPUT_KEY = "decision";
3886
+ var declinedCalls = /* @__PURE__ */ new WeakSet();
3887
+ function formatElicitationMessage(decision) {
3888
+ const lines = decision.options.map((option, index) => {
3889
+ const consequences = option.consequences.length === 0 ? "" : ` Consequences: ${option.consequences.join(" ")}`;
3890
+ return `${index + 1}. ${option.label} \u2014 ${option.description}${consequences}`;
3891
+ });
3892
+ return `${decision.prompt}
3893
+
3894
+ ${lines.join("\n")}`;
3895
+ }
3896
+ function presentDecision(runtime, call, tool, input) {
3897
+ const decision = buildDecisionContract(input.prompt, input.options);
3898
+ if (!runtime || !call || declinedCalls.has(call) || !runtime.supportsFormElicitation(call)) {
3899
+ return Promise.resolve(decisionToolResult(input));
3900
+ }
3901
+ const args = {};
3902
+ for (const option of decision.options) {
3903
+ if (option.nextAction.type === "call_tool" && option.nextAction.tool === tool) {
3904
+ args[option.id] = option.nextAction.arguments;
3905
+ }
3906
+ }
3907
+ if (Object.keys(args).length === 0) return Promise.resolve(decisionToolResult(input));
3908
+ return runtime.codec.mint({ v: 1, tool, decisionId: input.resultCode, arguments: args }, call).then(
3909
+ (requestState) => inputRequired2({
3910
+ requestState,
3911
+ inputRequests: {
3912
+ [DECISION_INPUT_KEY]: inputRequired2.elicit({
3913
+ message: formatElicitationMessage(decision),
3914
+ requestedSchema: {
3915
+ type: "object",
3916
+ properties: {
3917
+ choice: {
3918
+ type: "string",
3919
+ title: "Your choice",
3920
+ description: decision.prompt,
3921
+ oneOf: decision.options.map((option) => ({
3922
+ const: option.id,
3923
+ title: option.label
3924
+ }))
3925
+ }
3926
+ },
3927
+ required: ["choice"]
3928
+ }
3929
+ })
3930
+ }
3931
+ })
3932
+ );
3933
+ }
3934
+ function restoreDecisionChoice(call, tool) {
3935
+ const responses = call?.mcpReq.inputResponses;
3936
+ if (!call || !responses || !(DECISION_INPUT_KEY in responses)) return null;
3937
+ const state = call.mcpReq.requestState();
3938
+ if (!state || typeof state !== "object" || state.v !== 1 || state.tool !== tool) return null;
3939
+ const content = acceptedContent(responses, DECISION_INPUT_KEY);
3940
+ const choice = typeof content?.choice === "string" ? content.choice : void 0;
3941
+ const args = choice !== void 0 ? state.arguments[choice] : void 0;
3942
+ if (!args) {
3943
+ declinedCalls.add(call);
3944
+ return { kind: "declined" };
3945
+ }
3946
+ return { kind: "chosen", optionId: choice, arguments: args };
3947
+ }
3948
+ function withDecisionReentry(tool, handler) {
3949
+ return (args, call) => {
3950
+ const restored = restoreDecisionChoice(call, tool);
3951
+ if (restored?.kind === "chosen") return handler({ ...args, ...restored.arguments }, call);
3952
+ return handler(args, call);
3953
+ };
3954
+ }
3793
3955
 
3794
3956
  // src/tools/definitions.ts
3795
3957
  function text(resultCode, t, data = {}, outcome = "completed", nextActions = []) {
@@ -3802,9 +3964,14 @@ function text(resultCode, t, data = {}, outcome = "completed", nextActions = [])
3802
3964
  nextActions
3803
3965
  });
3804
3966
  }
3805
- function textJson(resultCode, header, obj, outcome = "completed") {
3806
- const summary = `${header}
3807
- ${JSON.stringify(obj, null, 2)}`;
3967
+ function textJson(resultCode, title, lead, obj, outcome = "completed") {
3968
+ const summary = summaryMarkdown({
3969
+ title,
3970
+ lead,
3971
+ raw: `\`\`\`json
3972
+ ${JSON.stringify(obj, null, 2)}
3973
+ \`\`\``
3974
+ });
3808
3975
  return structuredToolResult({
3809
3976
  schemaVersion: 1,
3810
3977
  outcome,
@@ -3847,7 +4014,8 @@ function analysisSummary(analysis) {
3847
4014
  function notDeployableResult(analysis) {
3848
4015
  return textJson(
3849
4016
  "site_analysis_not_deployable",
3850
- `This project is NOT deployable as-is. No files were uploaded and no API call was made.
4017
+ "This project is NOT deployable as-is",
4018
+ `No files were uploaded and no API call was made.
3851
4019
  Next action: ${analysis.suggestedNextAction}
3852
4020
  Analysis:`,
3853
4021
  analysisSummary(analysis),
@@ -3938,14 +4106,22 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
3938
4106
  };
3939
4107
  }
3940
4108
  }
3941
- function freeSiteCreationBarrier(sites, deployArguments, allowanceNetworkReference) {
3942
- const networkReferenceText = allowanceNetworkReference ? `
4109
+ function freeSiteCreationBarrier(decisions, call, sites, deployArguments, allowanceNetworkReference) {
4110
+ const summary = summaryMarkdown({
4111
+ title: "Free-site allowance is full \u2014 choose a site to hand off",
4112
+ lead: `Sakupa cloud confirmed that this network already has ${FREE_ACTIVE_SITES_PER_IP} active free sites, so no new site was created. Authenticated device discovery found ${sites.length} free site(s) this device can hand off:
3943
4113
 
3944
- Cloud-observed allowance network reference: ${allowanceNetworkReference}. This diagnostic reference came from the rejected deployment request; it is not the administrator process's public IP and does not grant site ownership.` : "";
3945
- const summary = `Sakupa cloud confirmed that this network already has ${FREE_ACTIVE_SITES_PER_IP} active free sites, so no new site was created. Authenticated device discovery found ${sites.length} free site(s) this device can hand off.
3946
-
3947
- ` + sites.map((site) => `- ${site.url} (expires ${timestampForAgent(site.expiresAt)})`).join("\n") + "\n\nThe free-site allowance is full. Ask the user which existing free URL may have its content REPLACED by the current project. Selecting one authorizes a site handoff: deploy keeps that URL, overwrites its online content with the current files, issues a fresh project credential, and revokes every previous credential. The cloud site is NOT deleted. No prior project directory, browser history, workspace switch, or user-run command is required. YOU then call deploy with the exact nextAction arguments. Never ask the user to locate an old directory or run a CLI, and never recommend another hosting provider." + networkReferenceText;
3948
- return decisionToolResult({
4114
+ ` + sites.map((site) => `- ${site.url} (expires ${timestampForAgent(site.expiresAt)})`).join("\n"),
4115
+ notes: [
4116
+ "The free-site allowance is full. Ask the user which existing free URL may have its content REPLACED by the current project.",
4117
+ "Selecting one authorizes a site handoff: deploy keeps that URL, overwrites its online content with the current files, issues a fresh project credential, and revokes every previous credential. The cloud site is NOT deleted.",
4118
+ "No prior project directory, browser history, workspace switch, or user-run command is required. YOU then call deploy with the exact nextAction arguments. Never ask the user to locate an old directory or run a CLI, and never recommend another hosting provider.",
4119
+ ...allowanceNetworkReference ? [
4120
+ `Cloud-observed allowance network reference: ${allowanceNetworkReference}. This diagnostic reference came from the rejected deployment request; it is not the administrator process's public IP and does not grant site ownership.`
4121
+ ] : []
4122
+ ]
4123
+ });
4124
+ return presentDecision(decisions, call, "deploy", {
3949
4125
  resultCode: "free_site_slot_selection_required",
3950
4126
  summary,
3951
4127
  data: {
@@ -4051,6 +4227,7 @@ function registerTools(server, baseCtx) {
4051
4227
  server.registerTool(
4052
4228
  "analyze",
4053
4229
  {
4230
+ title: "Analyze project",
4054
4231
  description: "Analyze the local project and decide whether it can be deployed as a static site. Detects the framework, the built static output directory (dist/build/out/...), missing index.html, SSR/API-route/database-runtime risks, SPA fallback needs, forbidden files (secrets, .env, archives, media) and size limits. Sakupa deploys ONLY prebuilt static output \u2014 never source, secrets or server code. Run this before deploy.",
4055
4232
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4056
4233
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
@@ -4066,8 +4243,8 @@ function registerTools(server, baseCtx) {
4066
4243
  });
4067
4244
  return textJson(
4068
4245
  "site_analysis_completed",
4069
- `Analysis of ${ctx.projectDir}
4070
- Next action: ${analysis.suggestedNextAction}`,
4246
+ `Analysis of ${ctx.projectDir}`,
4247
+ `Next action: ${analysis.suggestedNextAction}`,
4071
4248
  analysisSummary(analysis)
4072
4249
  );
4073
4250
  } catch (e) {
@@ -4078,6 +4255,7 @@ Next action: ${analysis.suggestedNextAction}`,
4078
4255
  server.registerTool(
4079
4256
  "deploy",
4080
4257
  {
4258
+ title: "Deploy site",
4081
4259
  description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://${previewHostPattern}) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscription-backed sites have no free-site expiry while the subscription remains active). Runs analyze first and refuses to upload source projects, secrets, .env files, archives, media or server code. The MCP process is locked to the current directory initialized by the no-argument init MCP tool; no tool argument can change that root. outputDir is a separate REQUIRED relative path supplied from the current project inspection. Never uploads anything when analysis says the project is not deployable.`,
4082
4260
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4083
4261
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -4109,7 +4287,7 @@ Next action: ${analysis.suggestedNextAction}`,
4109
4287
  lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
4110
4288
  })
4111
4289
  },
4112
- async (args, call) => {
4290
+ withDecisionReentry("deploy", async (args, call) => {
4113
4291
  let releaseHandoffLock;
4114
4292
  try {
4115
4293
  const ctx = await withProjectDir(baseCtx, call);
@@ -4126,7 +4304,7 @@ Next action: ${analysis.suggestedNextAction}`,
4126
4304
  outputDir: effectiveOutputDir,
4127
4305
  ...confirmation
4128
4306
  };
4129
- return decisionToolResult({
4307
+ return presentDecision(baseCtx.decisions, call, "deploy", {
4130
4308
  resultCode: "publish_directory_change_confirmation_required",
4131
4309
  summary: `This initialized project last published from "${recordedOutputDir}", but this request selected "${effectiveOutputDir}". Nothing was uploaded and the site was not changed. Show both paths to the user; only after explicit confirmation call deploy again with outputDirChangeConfirmed: true.`,
4132
4310
  data: {
@@ -4209,7 +4387,7 @@ Next action: ${analysis.suggestedNextAction}`,
4209
4387
  if (args.sakupaRelocationConfirmed !== true) {
4210
4388
  const confirmation = { sakupaRelocationConfirmed: true };
4211
4389
  const confirmArguments = { ...args, ...confirmation };
4212
- return decisionToolResult({
4390
+ return presentDecision(baseCtx.decisions, call, "deploy", {
4213
4391
  resultCode: "sakupa_relocation_confirmation_required",
4214
4392
  summary: `A nested Sakupa project marker exists at ${candidateDir}/.sakupa, but the active MCP Root is ${ctx.projectDir}. Nothing was moved or deployed. Show both paths to the user; after confirmation retry deploy with sakupaRelocationConfirmed:true. Sakupa will preserve credentials and refuse conflicts.`,
4215
4393
  data: {
@@ -4362,7 +4540,7 @@ Next action: ${analysis.suggestedNextAction}`,
4362
4540
  if (!existing && args.reuseSiteUrl === void 0 && args.publicConfirmed !== true) {
4363
4541
  const confirmation = { publicConfirmed: true };
4364
4542
  const confirmArguments = { ...args, ...confirmation };
4365
- return decisionToolResult({
4543
+ return presentDecision(baseCtx.decisions, call, "deploy", {
4366
4544
  resultCode: "public_deployment_confirmation_required",
4367
4545
  summary: `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Nothing has been uploaded or made public yet. The exact confirmation field is publicConfirmed: true.`,
4368
4546
  data: {
@@ -4394,7 +4572,7 @@ Next action: ${analysis.suggestedNextAction}`,
4394
4572
  if (args.reuseConfirmed !== true) {
4395
4573
  const confirmation = { reuseConfirmed: true };
4396
4574
  const confirmArguments = { ...args, publicConfirmed: true, ...confirmation };
4397
- return decisionToolResult({
4575
+ return presentDecision(baseCtx.decisions, call, "deploy", {
4398
4576
  resultCode: "free_site_reuse_confirmation_required",
4399
4577
  summary: `Nothing was changed. Reusing ${args.reuseSiteUrl} will replace all online content at that URL with the current project, issue a fresh credential here, and revoke every previous credential automatically. No old directory is needed. Show these consequences and call deploy with reuseConfirmed:true only after the user explicitly selects this URL.`,
4400
4578
  data: {
@@ -4526,7 +4704,13 @@ Next action: ${analysis.suggestedNextAction}`,
4526
4704
  if (isSakupaError(error) && error.code === "rate_limited") {
4527
4705
  const allowanceNetworkReference = allowanceNetworkReferenceFrom(error);
4528
4706
  if (deviceSites.length > 0) {
4529
- return freeSiteCreationBarrier(deviceSites, { ...args }, allowanceNetworkReference);
4707
+ return freeSiteCreationBarrier(
4708
+ baseCtx.decisions,
4709
+ call,
4710
+ deviceSites,
4711
+ { ...args },
4712
+ allowanceNetworkReference
4713
+ );
4530
4714
  }
4531
4715
  const networkReferenceText = allowanceNetworkReference ? ` Cloud-observed allowance network reference: ${allowanceNetworkReference}. This reference came from the rejected deployment request, not from the administrator process's public IP.` : "";
4532
4716
  return text(
@@ -4568,15 +4752,30 @@ Next action: ${analysis.suggestedNextAction}`,
4568
4752
  });
4569
4753
  return text(
4570
4754
  "site_published",
4571
- `Site published: ${finalized2.url}
4572
- ` + deploymentEnvironmentContext(ctx.apiBaseUrl) + `Project directory: ${ctx.projectDir}
4573
- Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
4574
- ` + (finalized2.expiresAt ? `Expiry deadline: ${timestampForAgent(finalized2.expiresAt)}
4575
- ` : "") + `
4576
- This is a FREE temporary preview: it stays live for ${FREE_SITE_TTL_HOURS} hours. Deploying again or calling refresh extends the validity; while a subscription remains active, this URL stays live without the free-site expiry. This is conditional on the subscription remaining active: do NOT describe the site as permanent or long-term, and do NOT say the subscription is bound to the site. The management credential was saved to the exact relative path .sakupa/site.json \u2014 preserve this complete path verbatim and never shorten it to site.json. Keep that file: it is the only way to manage this site.
4577
- ` + credentialGitReminder(ctx.projectDir) + (finalized2.warnings.length > 0 ? `
4578
- Warnings:
4579
- ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
4755
+ summaryMarkdown({
4756
+ title: `Site published: ${finalized2.url}`,
4757
+ lead: deploymentEnvironmentContext(ctx.apiBaseUrl) + `Project directory: ${ctx.projectDir}`,
4758
+ facts: [
4759
+ ["Public URL", finalized2.url],
4760
+ ["Project directory", ctx.projectDir],
4761
+ ["Files uploaded", `${uploaded2} (${finalized2.totalBytes} bytes)`],
4762
+ [
4763
+ "Expiry deadline",
4764
+ finalized2.expiresAt ? timestampForAgent(finalized2.expiresAt) : void 0
4765
+ ],
4766
+ ["Credential path", ".sakupa/site.json"]
4767
+ ],
4768
+ notes: [
4769
+ `This is a FREE temporary preview: it stays live for ${FREE_SITE_TTL_HOURS} hours. Deploying again or calling refresh extends the validity; while a subscription remains active, this URL stays live without the free-site expiry. This is conditional on the subscription remaining active: do NOT describe the site as permanent or long-term, and do NOT say the subscription is bound to the site.`,
4770
+ "The management credential was saved to the exact relative path .sakupa/site.json \u2014 preserve this complete path verbatim and never shorten it to site.json. Keep that file: it is the only way to manage this site.",
4771
+ ...[credentialGitReminder(ctx.projectDir)].filter((line) => line.trim().length > 0)
4772
+ ],
4773
+ next: ["`status`", "`subscribe` to keep the site online beyond the free period"],
4774
+ raw: finalized2.warnings.length > 0 ? `Warnings:
4775
+ \`\`\`json
4776
+ ${JSON.stringify(finalized2.warnings, null, 2)}
4777
+ \`\`\`` : void 0
4778
+ }),
4580
4779
  {
4581
4780
  siteId: created.siteId,
4582
4781
  shortId: created.shortId,
@@ -4664,18 +4863,43 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
4664
4863
  }
4665
4864
  return text(
4666
4865
  handoffPerformed ? "free_site_slot_reassigned" : "site_updated",
4667
- `Site updated: ${finalized.url}
4668
- ` + deploymentEnvironmentContext(ctx.apiBaseUrl) + `Project directory: ${ctx.projectDir}
4669
- Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
4670
- ` + (finalized.expiresAt ? `Validity refreshed \u2014 expiry deadline: ${timestampForAgent(finalized.expiresAt)}
4671
- ` : "") + (credentialRelocatedFrom.length > 0 ? `Credential binding relocated from ${credentialRelocatedFrom.join(", ")} to ${ctx.projectDir}/.sakupa; the existing site was preserved.
4672
- ` : "") + (handoffPerformed ? `Site handoff completed from the authenticated device list. The existing free-site URL stayed the same, the cloud site was NOT deleted, and its content was replaced. Sakupa issued a fresh project credential and revoked ${handoffRevokedCredentials} previous credential(s), so no old project can continue managing this URL.` + (handoffCleanup?.sourceCredentialRemoved ? " A matching obsolete local site.json was removed automatically.\n" : "\n") : "") + (credentialRotationResumed ? "A previously confirmed credential rotation was resumed safely before this deploy; every older credential is revoked.\n" : "") + (finalized.mode === "free" ? `
4673
- Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh call. While a subscription remains active, the site stays live without this free-site expiry.
4674
- ` : "\nThis site is subscription-backed and has no free-site expiry while the subscription remains active.\n") + (finalized.warnings.length > 0 ? `
4675
- Warnings:
4676
- ${JSON.stringify(finalized.warnings, null, 2)}` : "") + (credentialSecurity?.rotationRecommended ? `
4677
-
4678
- Optional security recommendation: this management credential was created at ${timestampForAgent(credentialSecurity.credentialCreatedAt)} and is older than 7 days. The deploy SUCCEEDED and rotation is not required. Ask the user whether they want to rotate; call rotate without confirmed:true to show the exact revocation preview. Never rotate automatically.` : ""),
4866
+ summaryMarkdown({
4867
+ title: `Site updated: ${finalized.url}`,
4868
+ lead: deploymentEnvironmentContext(ctx.apiBaseUrl) + `Project directory: ${ctx.projectDir}`,
4869
+ facts: [
4870
+ ["Public URL", finalized.url],
4871
+ ["Project directory", ctx.projectDir],
4872
+ ["Files uploaded", `${uploaded} (${finalized.totalBytes} bytes)`],
4873
+ ["Mode", finalized.mode],
4874
+ [
4875
+ "Validity refreshed \u2014 expiry deadline",
4876
+ finalized.expiresAt ? timestampForAgent(finalized.expiresAt) : void 0
4877
+ ]
4878
+ ],
4879
+ notes: [
4880
+ ...credentialRelocatedFrom.length > 0 ? [
4881
+ `Credential binding relocated from ${credentialRelocatedFrom.join(", ")} to ${ctx.projectDir}/.sakupa; the existing site was preserved.`
4882
+ ] : [],
4883
+ ...handoffPerformed ? [
4884
+ `Site handoff completed from the authenticated device list. The existing free-site URL stayed the same, the cloud site was NOT deleted, and its content was replaced. Sakupa issued a fresh project credential and revoked ${handoffRevokedCredentials} previous credential(s), so no old project can continue managing this URL.` + (handoffCleanup?.sourceCredentialRemoved ? " A matching obsolete local site.json was removed automatically." : "")
4885
+ ] : [],
4886
+ ...credentialRotationResumed ? [
4887
+ "A previously confirmed credential rotation was resumed safely before this deploy; every older credential is revoked."
4888
+ ] : [],
4889
+ finalized.mode === "free" ? `Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh call. While a subscription remains active, the site stays live without this free-site expiry.` : "This site is subscription-backed and has no free-site expiry while the subscription remains active.",
4890
+ ...credentialSecurity?.rotationRecommended ? [
4891
+ `Optional security recommendation: this management credential was created at ${timestampForAgent(credentialSecurity.credentialCreatedAt)} and is older than 7 days. The deploy SUCCEEDED and rotation is not required. Ask the user whether they want to rotate; call rotate without confirmed:true to show the exact revocation preview. Never rotate automatically.`
4892
+ ] : []
4893
+ ],
4894
+ next: [
4895
+ "`status`",
4896
+ ...credentialSecurity?.rotationRecommended ? ["`rotate` (optional, preview first) if the user wants a fresh credential"] : []
4897
+ ],
4898
+ raw: finalized.warnings.length > 0 ? `Warnings:
4899
+ \`\`\`json
4900
+ ${JSON.stringify(finalized.warnings, null, 2)}
4901
+ \`\`\`` : void 0
4902
+ }),
4679
4903
  {
4680
4904
  siteId: existing.siteId,
4681
4905
  url: finalized.url,
@@ -4729,11 +4953,12 @@ Optional security recommendation: this management credential was created at ${ti
4729
4953
  } finally {
4730
4954
  releaseHandoffLock?.();
4731
4955
  }
4732
- }
4956
+ })
4733
4957
  );
4734
4958
  server.registerTool(
4735
4959
  "refresh",
4736
4960
  {
4961
+ title: "Refresh free site",
4737
4962
  description: "Refresh the validity of the free temporary site WITHOUT uploading content. Uses the local credential in .sakupa/site.json. Subscription-backed sites have no free-site expiry while the subscription remains active and need no refresh.",
4738
4963
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4739
4964
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -4755,8 +4980,18 @@ Optional security recommendation: this management credential was created at ${ti
4755
4980
  }
4756
4981
  return text(
4757
4982
  "site_refreshed",
4758
- `Site validity refreshed (project: ${ctx.projectDir}). New expiry: ${timestampForAgent(res.expiresAt)}
4759
- NO content was uploaded or changed by this call \u2014 to publish new or edited files, run deploy. Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`,
4983
+ summaryMarkdown({
4984
+ title: "Site validity refreshed",
4985
+ facts: [
4986
+ ["Project directory", ctx.projectDir],
4987
+ ["New expiry", timestampForAgent(res.expiresAt)]
4988
+ ],
4989
+ notes: [
4990
+ "NO content was uploaded or changed by this call \u2014 to publish new or edited files, run deploy.",
4991
+ `Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`
4992
+ ],
4993
+ next: ["`status`", "`deploy` to publish changed files"]
4994
+ }),
4760
4995
  { siteId: site.siteId, expiresAt: res.expiresAt, projectDir: ctx.projectDir }
4761
4996
  );
4762
4997
  } catch (e) {
@@ -4767,6 +5002,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4767
5002
  server.registerTool(
4768
5003
  "status",
4769
5004
  {
5005
+ title: "Site status",
4770
5006
  description: "Show the current status of this project's Sakupa site: URL, mode (free/paid), expiry, custom domains, size, last deployment and warnings. For a paid site this tool also automatically returns the complete authoritative billing snapshot; users never need to know or name a separate billing tool to get accurate subscription information.",
4771
5007
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4772
5008
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -4782,7 +5018,12 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4782
5018
  const binding = res.pendingDomainBinding ? await describePendingBinding(ctx.client, site.credential, res.pendingDomainBinding) : void 0;
4783
5019
  return textJson(
4784
5020
  "status_returned",
4785
- billing ? `Site status with AUTHORITATIVE BILLING SNAPSHOT. When answering any subscription question, use the nested billing object and report the current plan, scheduled renewal or cancellation, effective time, current entitlement, billing period, usage state and one-time carry when present:${binding?.note ?? ""}` : `Site status:${binding?.note ?? ""}`,
5021
+ billing ? `Site status for ${res.url ?? res.siteId} with AUTHORITATIVE BILLING SNAPSHOT` : `Site status for ${res.url ?? res.siteId}`,
5022
+ [
5023
+ `Mode: ${res.mode} \xB7 Serving: ${res.servingMode} \xB7 Status: ${res.status}` + (res.expiresAt ? ` \xB7 Free expiry: ${timestampForAgent(res.expiresAt)}` : ""),
5024
+ billing ? "When answering any subscription question, use the nested billing object and report the current plan, scheduled renewal or cancellation, effective time, current entitlement, billing period, usage state and one-time carry when present." : "",
5025
+ binding?.note ?? ""
5026
+ ].filter(Boolean).join("\n"),
4786
5027
  {
4787
5028
  ...res,
4788
5029
  projectDir: ctx.projectDir,
@@ -4798,6 +5039,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4798
5039
  server.registerTool(
4799
5040
  "subscribe",
4800
5041
  {
5042
+ title: "Subscribe (Stripe Checkout)",
4801
5043
  description: `Create a Stripe Checkout link that subscribes THIS site to a Sakupa Hosting monthly plan (${planCatalog()}). While the subscription remains active, its ${previewHostPattern} URL stays live without the free 24-hour expiry. Binding a custom domain afterwards (bind) is an optional included extra and requires DNS control of that domain. Owner-only: requires this project's site credential (.sakupa/site.json) \u2014 deploy first. If the site outgrows its plan, Sakupa shows an over-limit notice and never changes billing automatically. The owner can explicitly choose another plan through Stripe Customer Portal. Card details are entered only on the Stripe-hosted page \u2014 never through the AI tool. Opening and completing Stripe Checkout is the final subscription confirmation.`,
4802
5044
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4803
5045
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -4821,11 +5063,23 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4821
5063
  );
4822
5064
  return text(
4823
5065
  "subscription_checkout_ready",
4824
- `Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, JPY ${res.monthlyPriceJpy}/month (Japanese yen)
4825
- ${res.checkoutUrl}
4826
-
4827
- Open this link in a browser to subscribe. Card data is entered only on the Stripe-hosted page \u2014 never give card numbers, passwords or security codes to the AI tool.
4828
- Once Stripe confirms payment and Sakupa synchronizes the subscription, the current URL stays live while that subscription remains active. Binding a custom domain (bind) is optional and still requires DNS verification.`,
5066
+ summaryMarkdown({
5067
+ title: "Stripe Checkout link \u2014 Sakupa Hosting for this site",
5068
+ lead: `Present this exact URL to the user: ${res.checkoutUrl}`,
5069
+ facts: [
5070
+ ["Plan", `${res.plan} plan, JPY ${res.monthlyPriceJpy}/month (Japanese yen)`],
5071
+ ["Checkout URL", res.checkoutUrl],
5072
+ ["Final confirmation", "Stripe-hosted checkout page"]
5073
+ ],
5074
+ steps: [
5075
+ "Open this link in a browser to subscribe. Card data is entered only on the Stripe-hosted page \u2014 never give card numbers, passwords or security codes to the AI tool."
5076
+ ],
5077
+ notes: [
5078
+ "Once Stripe confirms payment and Sakupa synchronizes the subscription, the current URL stays live while that subscription remains active.",
5079
+ "Binding a custom domain (bind) is optional and still requires DNS verification."
5080
+ ],
5081
+ next: ["`billing` after the user completes checkout"]
5082
+ }),
4829
5083
  {
4830
5084
  siteId: res.siteId,
4831
5085
  plan: res.plan,
@@ -4844,6 +5098,7 @@ Once Stripe confirms payment and Sakupa synchronizes the subscription, the curre
4844
5098
  server.registerTool(
4845
5099
  "bind",
4846
5100
  {
5101
+ title: "Bind custom domain",
4847
5102
  description: `Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the subscription-backed ${previewHostPattern} URL keeps working alongside it while the subscription is active. The binding unit is the APEX domain: binding example.com reserves routes for example.com and www.example.com, but ONLY www is required and judged for activation; the naked apex is optional because many DNS providers cannot point it. One apex TXT verification covers both. A site has one FINAL apex domain; starting a different apex begins a zero-downtime switch and the previous domain remains until the new www is live. The www CNAME must remain while bound. Requires an ACTIVE subscription (subscribe). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership, and bindings are ALWAYS challengeable: whoever proves CURRENT DNS control takes the domain, even from an existing binding (the displaced site keeps its subscription, content and subscription-backed Sakupa URL). Unverified requests expire after 72 hours. Call again with action "status" to check progress.`,
4848
5103
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4849
5104
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -4878,14 +5133,25 @@ Once Stripe confirms payment and Sakupa synchronizes the subscription, the curre
4878
5133
  const customerRecheckInstruction = `The customer cannot know whether the certificate is ready. Never use conditional readiness wording or ask the customer to decide the provider state. Tell the customer: "You do not need to judge readiness. After about one minute, reply: check domain status. I will check it once." The AI, not the customer, calls bind status exactly once. During this zero-downtime transition, the previously active domain may still serve. Once this binding becomes active, Sakupa retains only the last bound domain unit: ${apex2} and www.${apex2}.`;
4879
5134
  return text(
4880
5135
  res2.bindingStatus === "active" ? "domain_binding_active" : res2.bindingStatus === "provisioning" ? "domain_binding_provisioning" : "domain_verification_pending",
4881
- `Domain binding status for ${apex2}: ${res2.status}
4882
- ${res2.message}
4883
- The serving CNAME www.${apex2} \u2192 ${res2.servingTarget} must remain for as long as this domain is bound.` + (manualProviderRecheckRequired ? `
4884
-
4885
- ${customerRecheckInstruction}` : "") + "\n\n" + renderChecklistBlock(
4886
- diag,
4887
- "Fix any [MISSING]/[FIX] lines above, then re-run bind status; if still failing after the attempts below, show the user this checklist."
4888
- ),
5136
+ summaryMarkdown({
5137
+ title: `Domain binding status for ${apex2}: ${res2.status}`,
5138
+ lead: res2.message,
5139
+ facts: [
5140
+ ["Ownership verification", res2.status],
5141
+ ["Binding status", res2.bindingStatus],
5142
+ ["Provisioning phase", res2.provisioningPhase],
5143
+ ["Required serving record", `www.${apex2} CNAME \u2192 ${res2.servingTarget}`],
5144
+ ["Live for the customer", res2.bindingStatus === "active" ? "yes" : "not yet"]
5145
+ ],
5146
+ notes: [
5147
+ `The serving CNAME www.${apex2} \u2192 ${res2.servingTarget} must remain for as long as this domain is bound.`,
5148
+ ...manualProviderRecheckRequired ? [customerRecheckInstruction] : []
5149
+ ],
5150
+ raw: renderChecklistBlock(
5151
+ diag,
5152
+ "Fix any [MISSING]/[FIX] lines above, then re-run bind status; if still failing after the attempts below, show the user this checklist."
5153
+ )
5154
+ }),
4889
5155
  {
4890
5156
  verificationId: res2.verificationId,
4891
5157
  status: res2.status,
@@ -4940,17 +5206,28 @@ ${customerRecheckInstruction}` : "") + "\n\n" + renderChecklistBlock(
4940
5206
  ` : "";
4941
5207
  return text(
4942
5208
  "domain_verification_started",
4943
- switchNotice + `Domain binding started for ${apex} (routes reserved: ${res.includedHostnames.join(", ")}). Only www.${apex} is required to go live; the naked domain is optional.
4944
-
4945
- This is a STEP-BY-STEP setup \u2014 give the user ONE record at a time so they do not get overwhelmed and give up.
4946
-
4947
- STEP 1 of 2 \u2014 prove ownership. Add ONE record:
4948
-
4949
- TXT host: ${txtShort} value: ${res.verificationRecord.value}
4950
-
4951
- Host is the SHORT form: most panels append the domain automatically (the saved record must NOT show ${apex} twice in one name). Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
4952
-
4953
- When the user says the TXT is set, run bind "status". It verifies ownership and then hands back STEP 2 \u2014 a SINGLE www CNAME (there are NO certificate TXT records; HTTPS validates automatically over that CNAME). That CNAME must remain for as long as the domain stays bound to Sakupa. Each "status" checks the previous step and, unless something is misconfigured, advances to the next \u2014 so run it whenever the user reports a step done, NOT on a timer. Any later session can resume with action "status" alone; the verificationId is optional.`,
5209
+ summaryMarkdown({
5210
+ title: `Domain binding started for ${apex}`,
5211
+ lead: switchNotice + `Routes reserved: ${res.includedHostnames.join(", ")}. Only www.${apex} is required to go live; the naked domain is optional. This is a STEP-BY-STEP setup \u2014 give the user ONE record at a time so they do not get overwhelmed and give up.`,
5212
+ facts: [
5213
+ ["STEP 1 of 2 \u2014 record type", "TXT"],
5214
+ ["TXT host (short form)", txtShort],
5215
+ ["TXT value", res.verificationRecord.value],
5216
+ ["Full record name", res.verificationRecord.name],
5217
+ ["Challenge expires", "after 72 hours"]
5218
+ ],
5219
+ steps: [
5220
+ `STEP 1 of 2 \u2014 prove ownership. Add ONE record: TXT host: ${txtShort} value: ${res.verificationRecord.value}`,
5221
+ 'Tell the AI when the TXT record is set; it then runs bind "status", which verifies ownership and hands back STEP 2 \u2014 a SINGLE www CNAME.'
5222
+ ],
5223
+ notes: [
5224
+ `Host is the SHORT form: most panels append the domain automatically (the saved record must NOT show ${apex} twice in one name).`,
5225
+ "Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins.",
5226
+ "There are NO certificate TXT records; HTTPS validates automatically over the www CNAME. That CNAME must remain for as long as the domain stays bound to Sakupa.",
5227
+ 'Each "status" checks the previous step and, unless something is misconfigured, advances to the next \u2014 so run it whenever the user reports a step done, NOT on a timer. Any later session can resume with action "status" alone; the verificationId is optional.'
5228
+ ],
5229
+ next: ['`bind` with action "status" after the user reports the TXT record is set']
5230
+ }),
4954
5231
  {
4955
5232
  verificationId: res.verificationId,
4956
5233
  apexDomain: apex,
@@ -4983,6 +5260,7 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
4983
5260
  server.registerTool(
4984
5261
  "billing",
4985
5262
  {
5263
+ title: "Billing snapshot",
4986
5264
  description: "Return the sole authoritative source for this site's hosting subscription: current plan, next renewal plan or cancellation, effective time, payment state, current paid entitlement, reconciled paid usage or current free-site fair-use telemetry, estimated usage tier, bound custom domains and risks. Owner-only (uses the credential in .sakupa/site.json).",
4987
5265
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4988
5266
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -5016,9 +5294,14 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
5016
5294
  res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
5017
5295
  res.risks.pastDue ? "ATTENTION: renewal payment failing \u2014 update the payment method (portal). Serving continues while Stripe retries; if Stripe gives up, the site reverts to free." : void 0
5018
5296
  ].filter((l) => l !== void 0);
5019
- return textJson("billing_returned", `${lines.join("\n")}
5297
+ return textJson(
5298
+ "billing_returned",
5299
+ `AUTHORITATIVE BILLING SNAPSHOT for site ${res.siteId} (mode: ${res.mode})`,
5300
+ `${lines.slice(1).map((line) => `- ${line}`).join("\n")}
5020
5301
 
5021
- Full status:`, res);
5302
+ Full status:`,
5303
+ res
5304
+ );
5022
5305
  } catch (e) {
5023
5306
  return toolError(e);
5024
5307
  }
@@ -5027,6 +5310,7 @@ Full status:`, res);
5027
5310
  server.registerTool(
5028
5311
  "portal",
5029
5312
  {
5313
+ title: "Billing portal (Stripe)",
5030
5314
  description: "Open the Stripe-hosted billing portal for this site: update the payment method, view invoices, or cancel the subscription. All billing operations happen on the Stripe-hosted page \u2014 never inside the AI tool. With .sakupa/site.json, this opens the site-specific portal. Without the local credential, this returns Stripe's public no-code Customer Portal login page. The customer enters the checkout email and confirms a one-time passcode sent by Stripe. This never restores Sakupa site authority.",
5031
5315
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5032
5316
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -5044,7 +5328,12 @@ Full status:`, res);
5044
5328
  schemaVersion: 1,
5045
5329
  outcome: "waiting_user",
5046
5330
  resultCode: "site_billing_portal_ready",
5047
- summary: `Short-lived Stripe customer portal link created for this site: ${res2.portalUrl}. Any change still happens only on the Stripe-hosted page.`,
5331
+ summary: summaryMarkdown({
5332
+ title: "Stripe customer portal link ready",
5333
+ lead: `Short-lived Stripe customer portal link created for this site: ${res2.portalUrl}`,
5334
+ notes: ["Any change still happens only on the Stripe-hosted page."],
5335
+ next: ["`billing` after the user finishes on Stripe"]
5336
+ }),
5048
5337
  data: { scope: args.scope, portalUrl: res2.portalUrl },
5049
5338
  userAction: {
5050
5339
  type: "open_url",
@@ -5060,7 +5349,14 @@ Full status:`, res);
5060
5349
  schemaVersion: 1,
5061
5350
  outcome: "waiting_user",
5062
5351
  resultCode: "public_billing_recovery_portal_ready",
5063
- summary: `Stripe public email-OTP login page: ${res.portalUrl}. It uses a one-time passcode, does not recover the Sakupa key, and grants no site authority; when one email has several Customers, Stripe may open only the most recently created usable record.`,
5352
+ summary: summaryMarkdown({
5353
+ title: "Stripe public billing login page",
5354
+ lead: `Stripe public email-OTP login page: ${res.portalUrl}`,
5355
+ notes: [
5356
+ "It uses a one-time passcode, does not recover the Sakupa key, and grants no site authority.",
5357
+ "When one email has several Customers, Stripe may open only the most recently created usable record."
5358
+ ]
5359
+ }),
5064
5360
  data: {
5065
5361
  scope: args.scope,
5066
5362
  portalUrl: res.portalUrl,
@@ -5083,6 +5379,7 @@ Full status:`, res);
5083
5379
  server.registerTool(
5084
5380
  "recover",
5085
5381
  {
5382
+ title: "Recover site",
5086
5383
  description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Recovery is resumable: start stores local pending state; complete installs and writes the new .sakupa/site.json credential BEFORE requesting content; download uses that credential to reissue an archive and safely extract it into the explicitly selected outputDir without repeating DNS.",
5087
5384
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5088
5385
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -5096,7 +5393,7 @@ Full status:`, res);
5096
5393
  preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
5097
5394
  })
5098
5395
  },
5099
- async (args, call) => {
5396
+ withDecisionReentry("recover", async (args, call) => {
5100
5397
  try {
5101
5398
  const ctx = await withProjectDir(baseCtx, call);
5102
5399
  if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
@@ -5304,7 +5601,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
5304
5601
  ...revokeArguments,
5305
5602
  preserveExistingCredentials: true
5306
5603
  };
5307
- return decisionToolResult({
5604
+ return presentDecision(baseCtx.decisions, call, "recover", {
5308
5605
  resultCode: "domain_recovery_ready",
5309
5606
  summary: "DNS control is verified and recovery is ready to complete. Nothing was completed yet. The user must choose whether previous site credentials remain valid.",
5310
5607
  data: {
@@ -5439,11 +5736,12 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5439
5736
  } catch (e) {
5440
5737
  return toolError(e);
5441
5738
  }
5442
- }
5739
+ })
5443
5740
  );
5444
5741
  server.registerTool(
5445
5742
  "support",
5446
5743
  {
5744
+ title: "Support ticket",
5447
5745
  description: "Create a Sakupa support ticket for billing, payment, refund review, domain verification, deployment, serving or other issues the MCP cannot solve automatically. Do not include secrets, credentials or card data in the description.",
5448
5746
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5449
5747
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -5467,7 +5765,14 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5467
5765
  });
5468
5766
  return text(
5469
5767
  "support_ticket_created",
5470
- `Support ticket created: ${res.ticketId} (status: ${res.status}).`,
5768
+ summaryMarkdown({
5769
+ title: "Support ticket created",
5770
+ facts: [
5771
+ ["Ticket", res.ticketId],
5772
+ ["Status", res.status]
5773
+ ],
5774
+ notes: ["Wait for the Sakupa support follow-up; no further tool call is needed."]
5775
+ }),
5471
5776
  { ticketId: res.ticketId, status: res.status }
5472
5777
  );
5473
5778
  } catch (e) {
@@ -5478,6 +5783,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5478
5783
  server.registerTool(
5479
5784
  "report",
5480
5785
  {
5786
+ title: "Bug report",
5481
5787
  description: "LAST RESORT after help explicitly returns reportRecommended:true. Prepare and submit a sanitized product bug report using helpAuthorization from that diagnosis. Only whitelisted structured diagnostics are sent (tool name, error code/message, site id, bound domain, deployment id, timestamps, client/MCP version, request id) \u2014 NEVER file contents, source code, secrets, .env values or credentials. Without confirmSubmit: true the exact payload is shown for user review and nothing is submitted.",
5482
5788
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5483
5789
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -5499,7 +5805,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5499
5805
  confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
5500
5806
  })
5501
5807
  },
5502
- async (args, call) => {
5808
+ withDecisionReentry("report", async (args, call) => {
5503
5809
  try {
5504
5810
  requireReportAuthorization(baseCtx, args.helpAuthorization, args.toolName);
5505
5811
  const ctx = await optionalProjectContext(baseCtx, call);
@@ -5529,7 +5835,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5529
5835
  const contactNote = args.contactEmail !== void 0 ? "note that their contact email is attached for follow-up. " : "ASK THEM ONCE whether they want to attach a contact email for follow-up (optional \u2014 omit if declined; include it as contactEmail when they do). ";
5530
5836
  const confirmation = { confirmSubmit: true };
5531
5837
  const confirmArguments = { ...args, ...confirmation };
5532
- return decisionToolResult({
5838
+ return presentDecision(baseCtx.decisions, call, "report", {
5533
5839
  resultCode: "bug_report_preview_ready",
5534
5840
  outcome: "preview",
5535
5841
  summary: `Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Show it to the user, and ${contactNote}Exact payload:
@@ -5571,7 +5877,7 @@ Summary: ${res.sanitizedSummary}`,
5571
5877
  } catch (e) {
5572
5878
  return toolError(e);
5573
5879
  }
5574
- }
5880
+ })
5575
5881
  );
5576
5882
  }
5577
5883
 
@@ -5581,6 +5887,7 @@ function registerBillingTools(server, baseCtx) {
5581
5887
  server.registerTool(
5582
5888
  "plans",
5583
5889
  {
5890
+ title: "Hosting plan catalog",
5584
5891
  description: "Return the authoritative Sakupa monthly plan catalog, exact limits, prices, catalog version and plan-change billing rules. This is read-only and does not require a site.",
5585
5892
  inputSchema: z3.object({}),
5586
5893
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -5593,7 +5900,21 @@ function registerBillingTools(server, baseCtx) {
5593
5900
  schemaVersion: 1,
5594
5901
  outcome: "completed",
5595
5902
  resultCode: "billing_catalog_returned",
5596
- summary: `Returned ${catalog.plans.length} monthly plans; the Stripe-hosted page is the final confirmation surface for payment and plan changes.`,
5903
+ summary: summaryMarkdown({
5904
+ title: `Sakupa monthly plans (catalog ${catalog.catalogVersion})`,
5905
+ lead: `Returned ${catalog.plans.length} monthly plans; the Stripe-hosted page is the final confirmation surface for payment and plan changes. Prices are in JPY (Japanese yen).`,
5906
+ raw: [
5907
+ "| Plan | Rank | JPY / month | Storage (bytes) | Transfer (bytes) | Requests |",
5908
+ "|---|---|---|---|---|---|",
5909
+ ...catalog.plans.map(
5910
+ (plan) => `| ${plan.id} | ${plan.rank} | ${plan.monthlyPriceJpy} | ${plan.limits.storageBytes} | ${plan.limits.transferBytes} | ${plan.limits.requests} |`
5911
+ )
5912
+ ].join("\n"),
5913
+ notes: [
5914
+ `Upgrades bill immediately at full price (${catalog.upgradeChargeTiming}); downgrades take effect at ${catalog.downgradeEffectiveTiming}; unused transfer carries once (${catalog.upgradeTransferCarry}).`
5915
+ ],
5916
+ next: ["`subscribe` for a first subscription", "`change` for an existing subscription"]
5917
+ }),
5597
5918
  data: { catalog },
5598
5919
  nextActions: [{ tool: "subscribe", allowed: true }]
5599
5920
  });
@@ -5605,6 +5926,7 @@ function registerBillingTools(server, baseCtx) {
5605
5926
  server.registerTool(
5606
5927
  "change",
5607
5928
  {
5929
+ title: "Change subscription (Stripe)",
5608
5930
  description: "Create one Stripe-hosted subscription-management link. The user chooses the plan or period-end cancellation on Stripe; Sakupa never infers intent from the conversation. Creating the link does not change billing.",
5609
5931
  inputSchema: z3.object({
5610
5932
  operationId: z3.string().min(1)
@@ -5625,7 +5947,25 @@ function registerBillingTools(server, baseCtx) {
5625
5947
  outcome: "waiting_user",
5626
5948
  resultCode: "stripe_subscription_management_required",
5627
5949
  operationId: args.operationId,
5628
- summary: `Stripe subscription-management link (present this exact URL to the user): ${result.portalUrl}. The subscription has NOT changed yet. Stripe is the authoritative place to choose Water, Personal, Share, Business, or period-end cancellation. After Stripe confirmation, upgrades start a new billing cycle immediately at full price; downgrades and cancellation take effect at the current period end. The authoritative plan order from lowest to highest is Water, Personal, Share, Business; never describe a lower-ranked plan as an upgrade. Query billing after the user finishes.`,
5950
+ summary: summaryMarkdown({
5951
+ title: "Stripe subscription-management link ready",
5952
+ lead: `Stripe subscription-management link (present this exact URL to the user): ${result.portalUrl}`,
5953
+ facts: [
5954
+ ["Subscription changed", "NO \u2014 nothing changes until the user confirms on Stripe"],
5955
+ [
5956
+ "Plan order (lowest \u2192 highest)",
5957
+ Array.isArray(result.planOrder) ? result.planOrder.join(" \u2192 ") : void 0
5958
+ ]
5959
+ ],
5960
+ steps: [
5961
+ "Open the link and choose Water, Personal, Share, Business, or period-end cancellation on the Stripe-hosted page."
5962
+ ],
5963
+ notes: [
5964
+ "After Stripe confirmation, upgrades start a new billing cycle immediately at full price; downgrades and cancellation take effect at the current period end.",
5965
+ "The authoritative plan order from lowest to highest is Water, Personal, Share, Business; never describe a lower-ranked plan as an upgrade."
5966
+ ],
5967
+ next: ["`billing` after the user finishes on Stripe"]
5968
+ }),
5629
5969
  data: { portalUrl: result.portalUrl, result },
5630
5970
  userAction: {
5631
5971
  type: "open_url",
@@ -5698,7 +6038,7 @@ var TOOL_MANUALS = {
5698
6038
  init: {
5699
6039
  purpose: "Initialize the active IDE workspace as one Sakupa project.",
5700
6040
  sideEffects: "Creates only .sakupa/project.json locally; no API call, site or charge.",
5701
- preconditions: "Exactly one usable MCP workspace Root. If Roots are unavailable, help may authorize the AI to use CLI init.",
6041
+ preconditions: "Exactly one usable MCP workspace Root, or the SAKUPA_PROJECT_ROOT directory configured for this server when the client provides no Roots. If neither exists, help may authorize the AI to use CLI init.",
5702
6042
  parameterNames: [],
5703
6043
  parameters: "No parameters and no path argument.",
5704
6044
  warnings: [
@@ -5896,6 +6236,7 @@ function registerHelpTools(server, baseCtx) {
5896
6236
  server.registerTool(
5897
6237
  "init",
5898
6238
  {
6239
+ title: "Initialize project",
5899
6240
  description: "Initialize the active MCP workspace Root as a Sakupa project. Takes no path argument, creates only .sakupa/project.json at that exact Root, preserves site/recovery state, makes no API call and is idempotent. If MCP Roots are unavailable, call help; the AI may then use the no-argument CLI init itself.",
5900
6241
  inputSchema: z4.object({}),
5901
6242
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -5914,7 +6255,16 @@ function registerHelpTools(server, baseCtx) {
5914
6255
  schemaVersion: 1,
5915
6256
  outcome: "completed",
5916
6257
  resultCode: "project_initialized",
5917
- summary: `Sakupa project initialized and verified at the active workspace Root: ${ctx.projectDir}. .sakupa is at ${sakupaDirectory}; no cloud site was created and no charge occurred.`,
6258
+ summary: summaryMarkdown({
6259
+ title: "Sakupa project initialized",
6260
+ lead: `Initialized and verified at the active workspace Root: ${ctx.projectDir}. No cloud site was created and no charge occurred.`,
6261
+ facts: [
6262
+ ["Project root", ctx.projectDir],
6263
+ [".sakupa directory", sakupaDirectory],
6264
+ ["Binding source", ctx.bindingSource]
6265
+ ],
6266
+ next: ["`analyze`, then `deploy` with the exact relative outputDir"]
6267
+ }),
5918
6268
  data: {
5919
6269
  projectRoot: ctx.projectDir,
5920
6270
  sakupaDirectory,
@@ -5936,6 +6286,7 @@ function registerHelpTools(server, baseCtx) {
5936
6286
  server.registerTool(
5937
6287
  "help",
5938
6288
  {
6289
+ title: "Help and diagnosis",
5939
6290
  description: "FIRST troubleshooting tool for every Sakupa difficulty. With topic diagnose (default), inspect MCP Roots, cwd, binding and local state without requiring a project or calling the API. Use overview, terminology, or a tool name for complete usage, side effects, parameters and warnings. Only recommend report when help explicitly returns reportRecommended:true.",
5940
6291
  inputSchema: z4.object({
5941
6292
  topic: z4.enum(HELP_TOPICS).optional().default("diagnose"),
@@ -5963,7 +6314,25 @@ function registerHelpTools(server, baseCtx) {
5963
6314
  schemaVersion: 1,
5964
6315
  outcome: "completed",
5965
6316
  resultCode: "help_overview",
5966
- summary: `Sakupa tool overview and parameter names returned. Site handoff moves an existing free URL to the current project and replaces its credential as a safety consequence; credential rotation changes the credential in place solely for security. Both revoke prior values. When a result contains decision, present every option, select none by default, and copy only the user's selected option nextAction exactly. Use help topic:"terminology" for every site/credential distinction. On any failure call help with topic:"diagnose" before retrying, support or report.`,
6317
+ summary: summaryMarkdown({
6318
+ title: "Sakupa tool overview",
6319
+ lead: "Sakupa tool overview and parameter names returned.",
6320
+ raw: [
6321
+ "| Tool | Purpose | Parameters |",
6322
+ "|---|---|---|",
6323
+ ...TOOL_TOPICS.map(
6324
+ (tool) => `| \`${tool}\` | ${TOOL_MANUALS[tool].purpose} | ${TOOL_MANUALS[tool].parameterNames.join(", ") || "\u2014"} |`
6325
+ )
6326
+ ].join("\n"),
6327
+ notes: [
6328
+ "Site handoff moves an existing free URL to the current project and replaces its credential as a safety consequence; credential rotation changes the credential in place solely for security. Both revoke prior values.",
6329
+ "When a result contains decision, present every option, select none by default, and copy only the user's selected option nextAction exactly.",
6330
+ 'Use help topic:"terminology" for every site/credential distinction.'
6331
+ ],
6332
+ next: [
6333
+ '`help` with topic:"diagnose" on any failure \u2014 before retrying, support or report'
6334
+ ]
6335
+ }),
5967
6336
  data: {
5968
6337
  tools: catalog,
5969
6338
  toolOrder: TOOL_TOPICS,
@@ -5998,13 +6367,20 @@ function registerHelpTools(server, baseCtx) {
5998
6367
  schemaVersion: 1,
5999
6368
  outcome: "completed",
6000
6369
  resultCode: "help_tool_manual",
6001
- summary: `${args.topic}: ${manual.purpose}
6002
- Side effects: ${manual.sideEffects}
6003
- Preconditions: ${manual.preconditions}
6004
- Parameters: ${manual.parameters}
6005
- Warnings: ${manual.warnings.join(" ")}
6006
- Next: ${manual.nextStep}` + (terminologyText.length > 0 ? `
6007
- Terminology: ${terminologyText}` : ""),
6370
+ summary: summaryMarkdown({
6371
+ title: `${args.topic}: ${manual.purpose}`,
6372
+ facts: [
6373
+ ["Side effects", manual.sideEffects],
6374
+ ["Preconditions", manual.preconditions],
6375
+ ["Parameters", manual.parameters],
6376
+ ["Parameter names", manual.parameterNames.join(", ") || "(none)"]
6377
+ ],
6378
+ notes: [
6379
+ ...manual.warnings,
6380
+ ...terminologyText.length > 0 ? [`Terminology: ${terminologyText}`] : []
6381
+ ],
6382
+ next: [manual.nextStep]
6383
+ }),
6008
6384
  data: { tool: args.topic, ...manual, relatedTerminology },
6009
6385
  nextActions: []
6010
6386
  });
@@ -6052,7 +6428,23 @@ Terminology: ${terminologyText}` : ""),
6052
6428
  }
6053
6429
  ] : credentialRotationState === "pending" ? [{ tool: "rotate", allowed: true, reasonCode: "resume_confirmed_rotation" }] : diagnosis.diagnosisCode === "workspace_not_initialized" ? [{ tool: "init", allowed: true, reasonCode: "initialize_active_root" }] : [];
6054
6430
  const rotationGuidance = credentialRotationState === "pending" ? "A previously confirmed credential rotation is pending; call rotate with no arguments to resume it. The candidate credential is intentionally hidden." : credentialRotationState === "corrupted" ? "The local credential rotation journal is damaged. Preserve .sakupa/rotation.json, do not print, edit or delete it, and do not retry deploy or rotate until the file is recovered from a trusted backup or Sakupa support confirms the recovery path." : "";
6055
- const summary = `Help diagnosis: ${diagnosis.diagnosisCode}. ${diagnosis.guidance} MCP version: ${MCP_VERSION}. ${rotationGuidance} ` + (reportRecommended ? diagnosis.diagnosisCode === "project_bound" ? "Local project binding is healthy but the failure is an unclassified internal error. report is now available as the last resort; preview it before submission." : "The MCP Roots request itself failed with an unclassified internal error. report is now available as the last resort; preview it before submission." : "Do not submit report for this diagnosis; follow the guidance and retry help.");
6431
+ const summary = summaryMarkdown({
6432
+ title: `Help diagnosis: ${diagnosis.diagnosisCode}`,
6433
+ lead: diagnosis.guidance,
6434
+ facts: [
6435
+ ["MCP version", MCP_VERSION],
6436
+ ["Project marker", marker.kind],
6437
+ ["Site binding", site.kind],
6438
+ ["Recovery state", recoveryState],
6439
+ ["Credential rotation", credentialRotationState],
6440
+ ["Report recommended", reportRecommended ? "yes (last resort)" : "no"]
6441
+ ],
6442
+ notes: [
6443
+ ...rotationGuidance ? [rotationGuidance] : [],
6444
+ reportRecommended ? diagnosis.diagnosisCode === "project_bound" ? "Local project binding is healthy but the failure is an unclassified internal error. report is now available as the last resort; preview it before submission." : "The MCP Roots request itself failed with an unclassified internal error. report is now available as the last resort; preview it before submission." : "Do not submit report for this diagnosis; follow the guidance and retry help."
6445
+ ],
6446
+ next: nextActions.map((action) => `\`${action.tool}\` (${action.reasonCode})`)
6447
+ });
6056
6448
  return structuredToolResult({
6057
6449
  schemaVersion: 1,
6058
6450
  outcome: diagnosis.diagnosisCode === "project_bound" ? "completed" : "blocked",
@@ -6087,6 +6479,7 @@ function registerCredentialTools(server, baseCtx) {
6087
6479
  server.registerTool(
6088
6480
  "rotate",
6089
6481
  {
6482
+ title: "Rotate site credential",
6090
6483
  description: "Optionally rotate this site management credential. The first call is a read-only preview. Only confirmed:true after explicit user approval installs a locally generated new credential and revokes every previous credential. Rotation is never required to deploy.",
6091
6484
  inputSchema: z5.object({
6092
6485
  confirmed: z5.boolean().optional().describe(
@@ -6096,7 +6489,7 @@ function registerCredentialTools(server, baseCtx) {
6096
6489
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
6097
6490
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
6098
6491
  },
6099
- async (args, call) => {
6492
+ withDecisionReentry("rotate", async (args, call) => {
6100
6493
  let releaseLock;
6101
6494
  try {
6102
6495
  const ctx = await withProjectDir(baseCtx, call);
@@ -6118,7 +6511,19 @@ function registerCredentialTools(server, baseCtx) {
6118
6511
  schemaVersion: 1,
6119
6512
  outcome: "completed",
6120
6513
  resultCode: "credential_rotation_resumed",
6121
- summary: `Credential rotation resumed and completed for ${site.url ?? site.siteId}. The new credential is stored only in this project .sakupa/site.json. Every previous credential is revoked; old project folders and backup copies can no longer manage this site. No credential value is shown.`,
6514
+ summary: summaryMarkdown({
6515
+ title: `Credential rotation resumed and completed for ${site.url ?? site.siteId}`,
6516
+ facts: [
6517
+ ["Site", site.url ?? site.siteId],
6518
+ ["New credential stored at", ".sakupa/site.json (this project only)"],
6519
+ ["Previous credentials", "all revoked"]
6520
+ ],
6521
+ notes: [
6522
+ "Every previous credential is revoked; old project folders and backup copies can no longer manage this site.",
6523
+ "No credential value is shown."
6524
+ ],
6525
+ next: ["`status`"]
6526
+ }),
6122
6527
  data: {
6123
6528
  siteId: site.siteId,
6124
6529
  credentialCreatedAt: resumed.status.credentialCreatedAt,
@@ -6133,9 +6538,20 @@ function registerCredentialTools(server, baseCtx) {
6133
6538
  const status = await ctx.client.getCredentialStatus(site.siteId, site.credential);
6134
6539
  const confirmation = { confirmed: true };
6135
6540
  if (args.confirmed !== true) {
6136
- return decisionToolResult({
6541
+ return presentDecision(baseCtx.decisions, call, "rotate", {
6137
6542
  resultCode: "credential_rotation_confirmation_required",
6138
- summary: `Nothing was changed. Rotating the management credential for ${site.url ?? site.siteId} will generate a new credential locally, save it as the current credential in this project .sakupa/site.json, and revoke EVERY previous credential for this site\u2014including copies in old folders and backups. Rotation is optional and deploy remains available. Current credential created at: ${timestampForAgent(status.credentialCreatedAt)}. Exact confirm arguments: ${JSON.stringify(confirmation)}. Ask the user for explicit approval; never expose credential values.`,
6543
+ summary: summaryMarkdown({
6544
+ title: `Rotate the management credential for ${site.url ?? site.siteId}? Nothing was changed.`,
6545
+ facts: [
6546
+ ["Current credential created at", timestampForAgent(status.credentialCreatedAt)],
6547
+ ["Rotation", "optional; deploy remains available"],
6548
+ ["Exact confirm arguments", JSON.stringify(confirmation)]
6549
+ ],
6550
+ notes: [
6551
+ "Rotating generates a new credential locally, saves it as the current credential in this project .sakupa/site.json, and revokes EVERY previous credential for this site\u2014including copies in old folders and backups.",
6552
+ "Ask the user for explicit approval; never expose credential values."
6553
+ ]
6554
+ }),
6139
6555
  data: {
6140
6556
  siteId: site.siteId,
6141
6557
  credentialCreatedAt: status.credentialCreatedAt,
@@ -6186,7 +6602,21 @@ function registerCredentialTools(server, baseCtx) {
6186
6602
  schemaVersion: 1,
6187
6603
  outcome: "completed",
6188
6604
  resultCode: "credential_rotated",
6189
- summary: `Management credential rotated for ${site.url ?? site.siteId}. The new credential is stored only in this project .sakupa/site.json. Every previous credential is revoked; old project folders and backup copies can no longer manage this site. No credential value is shown.`,
6605
+ summary: summaryMarkdown({
6606
+ title: `Management credential rotated for ${site.url ?? site.siteId}`,
6607
+ facts: [
6608
+ ["New credential stored at", ".sakupa/site.json (this project only)"],
6609
+ [
6610
+ "Previous credentials revoked",
6611
+ completed.rotation?.revokedPreviousCredentials ?? "all"
6612
+ ]
6613
+ ],
6614
+ notes: [
6615
+ "Every previous credential is revoked; old project folders and backup copies can no longer manage this site.",
6616
+ "No credential value is shown."
6617
+ ],
6618
+ next: ["`status`"]
6619
+ }),
6190
6620
  data: {
6191
6621
  siteId: site.siteId,
6192
6622
  credentialCreatedAt: completed.status.credentialCreatedAt,
@@ -6203,7 +6633,7 @@ function registerCredentialTools(server, baseCtx) {
6203
6633
  } finally {
6204
6634
  releaseLock?.();
6205
6635
  }
6206
- }
6636
+ })
6207
6637
  );
6208
6638
  }
6209
6639
 
@@ -6406,11 +6836,13 @@ language. Keep option IDs, tool names, exact arguments, URLs, field names and co
6406
6836
  unchanged.
6407
6837
 
6408
6838
  Project directory contract: before the first deploy or a new recovery, CALL the init MCP tool with
6409
- NO path argument. init uses the IDE's exact MCP Root and creates the non-secret
6839
+ NO path argument. init uses the IDE's exact MCP Root \u2014 or, when the client provides no Roots, the
6840
+ SAKUPA_PROJECT_ROOT directory configured for this MCP server \u2014 and creates the non-secret
6410
6841
  .sakupa/project.json directly there. Do not merely print installation or CLI instructions when the
6411
- init tool is available. Only after help confirms that the client does not provide MCP Roots may the
6412
- AI itself use the CLI command "npx -y @sakupa/mcp@latest init" as a fallback; never ask the user to
6413
- run it. The CLI also accepts NO path argument. ONE MCP process = ONE Roots-first locked project = ONE site.
6842
+ init tool is available. Only after help confirms that the client provides neither MCP Roots nor
6843
+ SAKUPA_PROJECT_ROOT may the AI itself use the CLI command "npx -y @sakupa/mcp@latest init" as a
6844
+ fallback; never ask the user to run it. The CLI also accepts NO path argument. ONE MCP process = ONE
6845
+ locked project = ONE site.
6414
6846
  Site tools do not accept projectDir and cannot select another root; help, plans and report preview
6415
6847
  and public_recovery portal remain project-independent.
6416
6848
  Sakupa stores .sakupa/site.json and recovery state only in the locked directory; it never uses
@@ -6481,13 +6913,39 @@ Safety boundaries:
6481
6913
  a bound custom domain, a lost credential is unrecoverable by design. portal then opens
6482
6914
  Stripe's public no-code portal login, where the customer verifies the checkout email with a
6483
6915
  Stripe one-time passcode; it never restores site authority.`;
6916
+ var DECISION_ROUND_TIMEOUT_MS = 12e4;
6917
+ var DECISION_STATE_TTL_SECONDS = 900;
6918
+ function clientSupportsFormElicitation(server, call) {
6919
+ let declared;
6920
+ if (call?.mcpReq.envelope !== void 0) {
6921
+ const envelope = call.mcpReq.envelope;
6922
+ declared = envelope[CLIENT_CAPABILITIES_META_KEY];
6923
+ } else {
6924
+ declared = server.server.getClientCapabilities();
6925
+ }
6926
+ const elicitation = declared?.elicitation;
6927
+ if (!elicitation || typeof elicitation !== "object") return false;
6928
+ if (elicitation.form !== void 0) return true;
6929
+ return elicitation.url === void 0;
6930
+ }
6484
6931
  function createSakupaMcpServer(opts) {
6485
6932
  const client = opts.client ?? new HttpApiClient(
6486
6933
  new FetchTransport(opts.apiBaseUrl, { testAccessToken: opts.testAccessToken })
6487
6934
  );
6935
+ const decisionCodec = createRequestStateCodec({
6936
+ key: randomBytes2(32),
6937
+ ttlSeconds: DECISION_STATE_TTL_SECONDS
6938
+ });
6488
6939
  const server = new McpServer(
6489
6940
  { name: "sakupa", version: MCP_VERSION },
6490
- { instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)) }
6941
+ {
6942
+ instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)),
6943
+ // A native decision prompt must resolve well inside common IDE tool
6944
+ // deadlines; past this the legacy shim fails the round and the tool
6945
+ // falls back to the text decision on the next call.
6946
+ inputRequired: { roundTimeoutMs: DECISION_ROUND_TIMEOUT_MS },
6947
+ requestState: { verify: (state, call) => decisionCodec.verify(state, call) }
6948
+ }
6491
6949
  );
6492
6950
  const processCwd = resolve6(opts.projectDir ?? process.cwd());
6493
6951
  const rootsProvider = opts.rootsProvider ?? ((call) => readClientRoots(server, call));
@@ -6496,7 +6954,17 @@ function createSakupaMcpServer(opts) {
6496
6954
  apiBaseUrl: opts.apiBaseUrl,
6497
6955
  projectDir: processCwd,
6498
6956
  rootsProvider,
6499
- projectBinding: new ProjectBindingResolver(processCwd, rootsProvider)
6957
+ ...opts.projectRoot !== void 0 ? { configuredProjectRoot: opts.projectRoot } : {},
6958
+ projectBinding: new ProjectBindingResolver(
6959
+ processCwd,
6960
+ rootsProvider,
6961
+ MCP_ROOTS_TIMEOUT_MS,
6962
+ opts.projectRoot
6963
+ ),
6964
+ decisions: {
6965
+ supportsFormElicitation: (call) => clientSupportsFormElicitation(server, call),
6966
+ codec: decisionCodec
6967
+ }
6500
6968
  };
6501
6969
  registerTools(server, ctx);
6502
6970
  registerBillingTools(server, ctx);
@@ -6561,7 +7029,7 @@ async function main() {
6561
7029
  onerror: (error) => console.error("[sakupa-mcp] transport error:", error.message)
6562
7030
  });
6563
7031
  console.error(
6564
- `[sakupa-mcp] v${MCP_VERSION} serving stdio (api: ${config.apiBaseUrl}; process cwd fallback: ${process.cwd()}; MCP Roots preferred)`
7032
+ `[sakupa-mcp] v${MCP_VERSION} serving stdio (api: ${config.apiBaseUrl}; configured root: ${config.projectRoot ?? "none"}; process cwd fallback: ${process.cwd()}; MCP Roots preferred)`
6565
7033
  );
6566
7034
  }
6567
7035
  main().catch((err2) => {