@sakupa/mcp 1.2.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 +529 -106
  2. package/dist/index.js +529 -106
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -147,7 +147,7 @@ function isFreeSiteAllowanceNetworkReference(value) {
147
147
  }
148
148
 
149
149
  // ../core/dist/domain/version.js
150
- var SAKUPA_MCP_VERSION = "1.2.0";
150
+ var SAKUPA_MCP_VERSION = "1.3.0";
151
151
 
152
152
  // ../core/dist/domain/errors.js
153
153
  var HTTP_STATUS = {
@@ -2220,10 +2220,51 @@ function structuredToolResult(envelope) {
2220
2220
  return {
2221
2221
  content: [{ type: "text", text: `${envelope.summary}
2222
2222
 
2223
+ ---
2223
2224
  ${presentationFallback}` }],
2224
2225
  structuredContent: structuredEnvelope
2225
2226
  };
2226
2227
  }
2228
+ var SUMMARY_HEADINGS = {
2229
+ steps: "Do this yourself",
2230
+ notes: "Notes",
2231
+ next: "Next"
2232
+ };
2233
+ function tableCell(value) {
2234
+ return String(value).replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
2235
+ }
2236
+ function summaryMarkdown(sections) {
2237
+ const blocks = [`## ${sections.title.trim()}`];
2238
+ if (sections.lead?.trim()) blocks.push(sections.lead.trim());
2239
+ const facts = (sections.facts ?? []).filter(
2240
+ (row) => row[1] !== void 0 && row[1] !== ""
2241
+ );
2242
+ if (facts.length > 0) {
2243
+ blocks.push(
2244
+ [
2245
+ "| Item | Value |",
2246
+ "|---|---|",
2247
+ ...facts.map(([k, v]) => `| ${tableCell(k)} | ${tableCell(v)} |`)
2248
+ ].join("\n")
2249
+ );
2250
+ }
2251
+ if (sections.steps?.length) {
2252
+ blocks.push(
2253
+ `### ${SUMMARY_HEADINGS.steps}
2254
+ ${sections.steps.map((step, i) => `${i + 1}. ${step}`).join("\n")}`
2255
+ );
2256
+ }
2257
+ if (sections.notes?.length) {
2258
+ blocks.push(`### ${SUMMARY_HEADINGS.notes}
2259
+ ${sections.notes.map((n) => `- ${n}`).join("\n")}`);
2260
+ }
2261
+ if (sections.next?.length) {
2262
+ blocks.push(`### ${SUMMARY_HEADINGS.next}
2263
+ ${sections.next.map((n) => `- ${n}`).join("\n")}`);
2264
+ }
2265
+ if (sections.raw?.trim()) blocks.push(sections.raw.trim());
2266
+ return blocks.join("\n\n");
2267
+ }
2227
2268
  function timestampForAgent(exactTimestamp) {
2228
2269
  return timestampForAgentInZone(exactTimestamp, clientRuntimeTimeZone());
2229
2270
  }
@@ -2417,8 +2458,14 @@ function toolError(e) {
2417
2458
  const serverGuidance = isSakupaError(e) && errorCode !== "internal" && errorCode !== "unauthorized" && errorCode !== "upgrade_required" && e.message.trim().length > 0 ? e.message : void 0;
2418
2459
  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."));
2419
2460
  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.";
2420
- const userFacingSummary = `Customer meaning: ${customerMeaning}
2421
- Technical context for the AI: ${safeSummary}`;
2461
+ const userFacingSummary = summaryMarkdown({
2462
+ title: `Sakupa could not complete this operation (${errorCode})`,
2463
+ lead: `Customer meaning: ${customerMeaning}`,
2464
+ notes: [`Technical context for the AI: ${safeSummary}`],
2465
+ next: [
2466
+ '`help` with topic "diagnose", the failed tool name and this error code \u2014 before any retry, support request or report'
2467
+ ]
2468
+ });
2422
2469
  const result = structuredToolResult({
2423
2470
  schemaVersion: 1,
2424
2471
  outcome: "failed",
@@ -3750,6 +3797,7 @@ async function resumeCredentialRotation(client, projectDir, site, apiBaseUrl) {
3750
3797
  }
3751
3798
 
3752
3799
  // src/tools/decision.ts
3800
+ import { acceptedContent, inputRequired as inputRequired2 } from "@modelcontextprotocol/server";
3753
3801
  var DECISION_PRESENTATION_POLICY = {
3754
3802
  translateFields: [
3755
3803
  "decision.prompt",
@@ -3842,11 +3890,14 @@ function buildDecisionContract(prompt, options) {
3842
3890
  function formatDecisionFallback(decision) {
3843
3891
  const options = decision.options.map((option, index) => {
3844
3892
  const consequences = option.consequences.length === 0 ? "" : `
3845
- Consequences: ${option.consequences.join(" ")}`;
3893
+ - Consequences: ${option.consequences.join(" ")}`;
3846
3894
  let exactAction;
3847
3895
  switch (option.nextAction.type) {
3848
3896
  case "call_tool":
3849
- exactAction = `If the user selects this option, call ${option.nextAction.tool} with these exact arguments: ${JSON.stringify(option.nextAction.arguments)}.`;
3897
+ exactAction = `If the user selects this option, call \`${option.nextAction.tool}\` with these exact arguments:
3898
+ \`\`\`json
3899
+ ${JSON.stringify(option.nextAction.arguments)}
3900
+ \`\`\``;
3850
3901
  break;
3851
3902
  case "open_url":
3852
3903
  exactAction = `If the user selects this option, present this exact URL: ${option.nextAction.url}.`;
@@ -3855,11 +3906,13 @@ function formatDecisionFallback(decision) {
3855
3906
  exactAction = "If the user selects this option, call no tool and make no change.";
3856
3907
  break;
3857
3908
  }
3858
- return `${index + 1}. [${option.id}] ${option.label}
3909
+ return `${index + 1}. [${option.id}] **${option.label}**
3859
3910
  ${option.description}${consequences}
3860
3911
  ${exactAction}`;
3861
3912
  });
3862
- return `USER DECISION REQUIRED: ${decision.prompt}
3913
+ return `### USER DECISION REQUIRED
3914
+ ${decision.prompt}
3915
+
3863
3916
  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.
3864
3917
 
3865
3918
  ` + options.join("\n\n");
@@ -3939,6 +3992,76 @@ function noActionDecisionOption(input) {
3939
3992
  nextAction: { type: "none" }
3940
3993
  };
3941
3994
  }
3995
+ var DECISION_INPUT_KEY = "decision";
3996
+ var declinedCalls = /* @__PURE__ */ new WeakSet();
3997
+ function formatElicitationMessage(decision) {
3998
+ const lines = decision.options.map((option, index) => {
3999
+ const consequences = option.consequences.length === 0 ? "" : ` Consequences: ${option.consequences.join(" ")}`;
4000
+ return `${index + 1}. ${option.label} \u2014 ${option.description}${consequences}`;
4001
+ });
4002
+ return `${decision.prompt}
4003
+
4004
+ ${lines.join("\n")}`;
4005
+ }
4006
+ function presentDecision(runtime, call, tool, input) {
4007
+ const decision = buildDecisionContract(input.prompt, input.options);
4008
+ if (!runtime || !call || declinedCalls.has(call) || !runtime.supportsFormElicitation(call)) {
4009
+ return Promise.resolve(decisionToolResult(input));
4010
+ }
4011
+ const args = {};
4012
+ for (const option of decision.options) {
4013
+ if (option.nextAction.type === "call_tool" && option.nextAction.tool === tool) {
4014
+ args[option.id] = option.nextAction.arguments;
4015
+ }
4016
+ }
4017
+ if (Object.keys(args).length === 0) return Promise.resolve(decisionToolResult(input));
4018
+ return runtime.codec.mint({ v: 1, tool, decisionId: input.resultCode, arguments: args }, call).then(
4019
+ (requestState) => inputRequired2({
4020
+ requestState,
4021
+ inputRequests: {
4022
+ [DECISION_INPUT_KEY]: inputRequired2.elicit({
4023
+ message: formatElicitationMessage(decision),
4024
+ requestedSchema: {
4025
+ type: "object",
4026
+ properties: {
4027
+ choice: {
4028
+ type: "string",
4029
+ title: "Your choice",
4030
+ description: decision.prompt,
4031
+ oneOf: decision.options.map((option) => ({
4032
+ const: option.id,
4033
+ title: option.label
4034
+ }))
4035
+ }
4036
+ },
4037
+ required: ["choice"]
4038
+ }
4039
+ })
4040
+ }
4041
+ })
4042
+ );
4043
+ }
4044
+ function restoreDecisionChoice(call, tool) {
4045
+ const responses = call?.mcpReq.inputResponses;
4046
+ if (!call || !responses || !(DECISION_INPUT_KEY in responses)) return null;
4047
+ const state = call.mcpReq.requestState();
4048
+ if (!state || typeof state !== "object" || state.v !== 1 || state.tool !== tool) return null;
4049
+ const content = acceptedContent(responses, DECISION_INPUT_KEY);
4050
+ const choice = typeof content?.choice === "string" ? content.choice : void 0;
4051
+ const args = choice !== void 0 ? state.arguments[choice] : void 0;
4052
+ if (!args) {
4053
+ declinedCalls.add(call);
4054
+ return { kind: "declined" };
4055
+ }
4056
+ return { kind: "chosen", optionId: choice, arguments: args };
4057
+ }
4058
+ function withDecisionReentry(tool, handler) {
4059
+ return (args, call) => {
4060
+ const restored = restoreDecisionChoice(call, tool);
4061
+ if (restored?.kind === "chosen") return handler({ ...args, ...restored.arguments }, call);
4062
+ return handler(args, call);
4063
+ };
4064
+ }
3942
4065
 
3943
4066
  // src/tools/definitions.ts
3944
4067
  function text(resultCode, t, data = {}, outcome = "completed", nextActions = []) {
@@ -3951,9 +4074,14 @@ function text(resultCode, t, data = {}, outcome = "completed", nextActions = [])
3951
4074
  nextActions
3952
4075
  });
3953
4076
  }
3954
- function textJson(resultCode, header, obj, outcome = "completed") {
3955
- const summary = `${header}
3956
- ${JSON.stringify(obj, null, 2)}`;
4077
+ function textJson(resultCode, title, lead, obj, outcome = "completed") {
4078
+ const summary = summaryMarkdown({
4079
+ title,
4080
+ lead,
4081
+ raw: `\`\`\`json
4082
+ ${JSON.stringify(obj, null, 2)}
4083
+ \`\`\``
4084
+ });
3957
4085
  return structuredToolResult({
3958
4086
  schemaVersion: 1,
3959
4087
  outcome,
@@ -3996,7 +4124,8 @@ function analysisSummary(analysis) {
3996
4124
  function notDeployableResult(analysis) {
3997
4125
  return textJson(
3998
4126
  "site_analysis_not_deployable",
3999
- `This project is NOT deployable as-is. No files were uploaded and no API call was made.
4127
+ "This project is NOT deployable as-is",
4128
+ `No files were uploaded and no API call was made.
4000
4129
  Next action: ${analysis.suggestedNextAction}
4001
4130
  Analysis:`,
4002
4131
  analysisSummary(analysis),
@@ -4087,14 +4216,22 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
4087
4216
  };
4088
4217
  }
4089
4218
  }
4090
- function freeSiteCreationBarrier(sites, deployArguments, allowanceNetworkReference) {
4091
- const networkReferenceText = allowanceNetworkReference ? `
4219
+ function freeSiteCreationBarrier(decisions, call, sites, deployArguments, allowanceNetworkReference) {
4220
+ const summary = summaryMarkdown({
4221
+ title: "Free-site allowance is full \u2014 choose a site to hand off",
4222
+ 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:
4092
4223
 
4093
- 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.` : "";
4094
- 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.
4095
-
4096
- ` + 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;
4097
- return decisionToolResult({
4224
+ ` + sites.map((site) => `- ${site.url} (expires ${timestampForAgent(site.expiresAt)})`).join("\n"),
4225
+ notes: [
4226
+ "The free-site allowance is full. Ask the user which existing free URL may have its content REPLACED by the current project.",
4227
+ "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.",
4228
+ "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.",
4229
+ ...allowanceNetworkReference ? [
4230
+ `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.`
4231
+ ] : []
4232
+ ]
4233
+ });
4234
+ return presentDecision(decisions, call, "deploy", {
4098
4235
  resultCode: "free_site_slot_selection_required",
4099
4236
  summary,
4100
4237
  data: {
@@ -4200,6 +4337,7 @@ function registerTools(server, baseCtx) {
4200
4337
  server.registerTool(
4201
4338
  "analyze",
4202
4339
  {
4340
+ title: "Analyze project",
4203
4341
  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.",
4204
4342
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4205
4343
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
@@ -4215,8 +4353,8 @@ function registerTools(server, baseCtx) {
4215
4353
  });
4216
4354
  return textJson(
4217
4355
  "site_analysis_completed",
4218
- `Analysis of ${ctx.projectDir}
4219
- Next action: ${analysis.suggestedNextAction}`,
4356
+ `Analysis of ${ctx.projectDir}`,
4357
+ `Next action: ${analysis.suggestedNextAction}`,
4220
4358
  analysisSummary(analysis)
4221
4359
  );
4222
4360
  } catch (e) {
@@ -4227,6 +4365,7 @@ Next action: ${analysis.suggestedNextAction}`,
4227
4365
  server.registerTool(
4228
4366
  "deploy",
4229
4367
  {
4368
+ title: "Deploy site",
4230
4369
  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.`,
4231
4370
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4232
4371
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -4258,7 +4397,7 @@ Next action: ${analysis.suggestedNextAction}`,
4258
4397
  lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
4259
4398
  })
4260
4399
  },
4261
- async (args, call) => {
4400
+ withDecisionReentry("deploy", async (args, call) => {
4262
4401
  let releaseHandoffLock;
4263
4402
  try {
4264
4403
  const ctx = await withProjectDir(baseCtx, call);
@@ -4275,7 +4414,7 @@ Next action: ${analysis.suggestedNextAction}`,
4275
4414
  outputDir: effectiveOutputDir,
4276
4415
  ...confirmation
4277
4416
  };
4278
- return decisionToolResult({
4417
+ return presentDecision(baseCtx.decisions, call, "deploy", {
4279
4418
  resultCode: "publish_directory_change_confirmation_required",
4280
4419
  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.`,
4281
4420
  data: {
@@ -4358,7 +4497,7 @@ Next action: ${analysis.suggestedNextAction}`,
4358
4497
  if (args.sakupaRelocationConfirmed !== true) {
4359
4498
  const confirmation = { sakupaRelocationConfirmed: true };
4360
4499
  const confirmArguments = { ...args, ...confirmation };
4361
- return decisionToolResult({
4500
+ return presentDecision(baseCtx.decisions, call, "deploy", {
4362
4501
  resultCode: "sakupa_relocation_confirmation_required",
4363
4502
  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.`,
4364
4503
  data: {
@@ -4511,7 +4650,7 @@ Next action: ${analysis.suggestedNextAction}`,
4511
4650
  if (!existing && args.reuseSiteUrl === void 0 && args.publicConfirmed !== true) {
4512
4651
  const confirmation = { publicConfirmed: true };
4513
4652
  const confirmArguments = { ...args, ...confirmation };
4514
- return decisionToolResult({
4653
+ return presentDecision(baseCtx.decisions, call, "deploy", {
4515
4654
  resultCode: "public_deployment_confirmation_required",
4516
4655
  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.`,
4517
4656
  data: {
@@ -4543,7 +4682,7 @@ Next action: ${analysis.suggestedNextAction}`,
4543
4682
  if (args.reuseConfirmed !== true) {
4544
4683
  const confirmation = { reuseConfirmed: true };
4545
4684
  const confirmArguments = { ...args, publicConfirmed: true, ...confirmation };
4546
- return decisionToolResult({
4685
+ return presentDecision(baseCtx.decisions, call, "deploy", {
4547
4686
  resultCode: "free_site_reuse_confirmation_required",
4548
4687
  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.`,
4549
4688
  data: {
@@ -4675,7 +4814,13 @@ Next action: ${analysis.suggestedNextAction}`,
4675
4814
  if (isSakupaError(error) && error.code === "rate_limited") {
4676
4815
  const allowanceNetworkReference = allowanceNetworkReferenceFrom(error);
4677
4816
  if (deviceSites.length > 0) {
4678
- return freeSiteCreationBarrier(deviceSites, { ...args }, allowanceNetworkReference);
4817
+ return freeSiteCreationBarrier(
4818
+ baseCtx.decisions,
4819
+ call,
4820
+ deviceSites,
4821
+ { ...args },
4822
+ allowanceNetworkReference
4823
+ );
4679
4824
  }
4680
4825
  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.` : "";
4681
4826
  return text(
@@ -4717,15 +4862,30 @@ Next action: ${analysis.suggestedNextAction}`,
4717
4862
  });
4718
4863
  return text(
4719
4864
  "site_published",
4720
- `Site published: ${finalized2.url}
4721
- ` + deploymentEnvironmentContext(ctx.apiBaseUrl) + `Project directory: ${ctx.projectDir}
4722
- Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
4723
- ` + (finalized2.expiresAt ? `Expiry deadline: ${timestampForAgent(finalized2.expiresAt)}
4724
- ` : "") + `
4725
- 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.
4726
- ` + credentialGitReminder(ctx.projectDir) + (finalized2.warnings.length > 0 ? `
4727
- Warnings:
4728
- ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
4865
+ summaryMarkdown({
4866
+ title: `Site published: ${finalized2.url}`,
4867
+ lead: deploymentEnvironmentContext(ctx.apiBaseUrl) + `Project directory: ${ctx.projectDir}`,
4868
+ facts: [
4869
+ ["Public URL", finalized2.url],
4870
+ ["Project directory", ctx.projectDir],
4871
+ ["Files uploaded", `${uploaded2} (${finalized2.totalBytes} bytes)`],
4872
+ [
4873
+ "Expiry deadline",
4874
+ finalized2.expiresAt ? timestampForAgent(finalized2.expiresAt) : void 0
4875
+ ],
4876
+ ["Credential path", ".sakupa/site.json"]
4877
+ ],
4878
+ notes: [
4879
+ `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.`,
4880
+ "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.",
4881
+ ...[credentialGitReminder(ctx.projectDir)].filter((line) => line.trim().length > 0)
4882
+ ],
4883
+ next: ["`status`", "`subscribe` to keep the site online beyond the free period"],
4884
+ raw: finalized2.warnings.length > 0 ? `Warnings:
4885
+ \`\`\`json
4886
+ ${JSON.stringify(finalized2.warnings, null, 2)}
4887
+ \`\`\`` : void 0
4888
+ }),
4729
4889
  {
4730
4890
  siteId: created.siteId,
4731
4891
  shortId: created.shortId,
@@ -4813,18 +4973,43 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
4813
4973
  }
4814
4974
  return text(
4815
4975
  handoffPerformed ? "free_site_slot_reassigned" : "site_updated",
4816
- `Site updated: ${finalized.url}
4817
- ` + deploymentEnvironmentContext(ctx.apiBaseUrl) + `Project directory: ${ctx.projectDir}
4818
- Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
4819
- ` + (finalized.expiresAt ? `Validity refreshed \u2014 expiry deadline: ${timestampForAgent(finalized.expiresAt)}
4820
- ` : "") + (credentialRelocatedFrom.length > 0 ? `Credential binding relocated from ${credentialRelocatedFrom.join(", ")} to ${ctx.projectDir}/.sakupa; the existing site was preserved.
4821
- ` : "") + (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" ? `
4822
- 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.
4823
- ` : "\nThis site is subscription-backed and has no free-site expiry while the subscription remains active.\n") + (finalized.warnings.length > 0 ? `
4824
- Warnings:
4825
- ${JSON.stringify(finalized.warnings, null, 2)}` : "") + (credentialSecurity?.rotationRecommended ? `
4826
-
4827
- 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.` : ""),
4976
+ summaryMarkdown({
4977
+ title: `Site updated: ${finalized.url}`,
4978
+ lead: deploymentEnvironmentContext(ctx.apiBaseUrl) + `Project directory: ${ctx.projectDir}`,
4979
+ facts: [
4980
+ ["Public URL", finalized.url],
4981
+ ["Project directory", ctx.projectDir],
4982
+ ["Files uploaded", `${uploaded} (${finalized.totalBytes} bytes)`],
4983
+ ["Mode", finalized.mode],
4984
+ [
4985
+ "Validity refreshed \u2014 expiry deadline",
4986
+ finalized.expiresAt ? timestampForAgent(finalized.expiresAt) : void 0
4987
+ ]
4988
+ ],
4989
+ notes: [
4990
+ ...credentialRelocatedFrom.length > 0 ? [
4991
+ `Credential binding relocated from ${credentialRelocatedFrom.join(", ")} to ${ctx.projectDir}/.sakupa; the existing site was preserved.`
4992
+ ] : [],
4993
+ ...handoffPerformed ? [
4994
+ `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." : "")
4995
+ ] : [],
4996
+ ...credentialRotationResumed ? [
4997
+ "A previously confirmed credential rotation was resumed safely before this deploy; every older credential is revoked."
4998
+ ] : [],
4999
+ 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.",
5000
+ ...credentialSecurity?.rotationRecommended ? [
5001
+ `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.`
5002
+ ] : []
5003
+ ],
5004
+ next: [
5005
+ "`status`",
5006
+ ...credentialSecurity?.rotationRecommended ? ["`rotate` (optional, preview first) if the user wants a fresh credential"] : []
5007
+ ],
5008
+ raw: finalized.warnings.length > 0 ? `Warnings:
5009
+ \`\`\`json
5010
+ ${JSON.stringify(finalized.warnings, null, 2)}
5011
+ \`\`\`` : void 0
5012
+ }),
4828
5013
  {
4829
5014
  siteId: existing.siteId,
4830
5015
  url: finalized.url,
@@ -4878,11 +5063,12 @@ Optional security recommendation: this management credential was created at ${ti
4878
5063
  } finally {
4879
5064
  releaseHandoffLock?.();
4880
5065
  }
4881
- }
5066
+ })
4882
5067
  );
4883
5068
  server.registerTool(
4884
5069
  "refresh",
4885
5070
  {
5071
+ title: "Refresh free site",
4886
5072
  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.",
4887
5073
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4888
5074
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -4904,8 +5090,18 @@ Optional security recommendation: this management credential was created at ${ti
4904
5090
  }
4905
5091
  return text(
4906
5092
  "site_refreshed",
4907
- `Site validity refreshed (project: ${ctx.projectDir}). New expiry: ${timestampForAgent(res.expiresAt)}
4908
- 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.`,
5093
+ summaryMarkdown({
5094
+ title: "Site validity refreshed",
5095
+ facts: [
5096
+ ["Project directory", ctx.projectDir],
5097
+ ["New expiry", timestampForAgent(res.expiresAt)]
5098
+ ],
5099
+ notes: [
5100
+ "NO content was uploaded or changed by this call \u2014 to publish new or edited files, run deploy.",
5101
+ `Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`
5102
+ ],
5103
+ next: ["`status`", "`deploy` to publish changed files"]
5104
+ }),
4909
5105
  { siteId: site.siteId, expiresAt: res.expiresAt, projectDir: ctx.projectDir }
4910
5106
  );
4911
5107
  } catch (e) {
@@ -4916,6 +5112,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4916
5112
  server.registerTool(
4917
5113
  "status",
4918
5114
  {
5115
+ title: "Site status",
4919
5116
  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.",
4920
5117
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4921
5118
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -4931,7 +5128,12 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4931
5128
  const binding = res.pendingDomainBinding ? await describePendingBinding(ctx.client, site.credential, res.pendingDomainBinding) : void 0;
4932
5129
  return textJson(
4933
5130
  "status_returned",
4934
- 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 ?? ""}`,
5131
+ billing ? `Site status for ${res.url ?? res.siteId} with AUTHORITATIVE BILLING SNAPSHOT` : `Site status for ${res.url ?? res.siteId}`,
5132
+ [
5133
+ `Mode: ${res.mode} \xB7 Serving: ${res.servingMode} \xB7 Status: ${res.status}` + (res.expiresAt ? ` \xB7 Free expiry: ${timestampForAgent(res.expiresAt)}` : ""),
5134
+ 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." : "",
5135
+ binding?.note ?? ""
5136
+ ].filter(Boolean).join("\n"),
4935
5137
  {
4936
5138
  ...res,
4937
5139
  projectDir: ctx.projectDir,
@@ -4947,6 +5149,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4947
5149
  server.registerTool(
4948
5150
  "subscribe",
4949
5151
  {
5152
+ title: "Subscribe (Stripe Checkout)",
4950
5153
  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.`,
4951
5154
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4952
5155
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -4970,11 +5173,23 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4970
5173
  );
4971
5174
  return text(
4972
5175
  "subscription_checkout_ready",
4973
- `Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, JPY ${res.monthlyPriceJpy}/month (Japanese yen)
4974
- ${res.checkoutUrl}
4975
-
4976
- 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.
4977
- 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.`,
5176
+ summaryMarkdown({
5177
+ title: "Stripe Checkout link \u2014 Sakupa Hosting for this site",
5178
+ lead: `Present this exact URL to the user: ${res.checkoutUrl}`,
5179
+ facts: [
5180
+ ["Plan", `${res.plan} plan, JPY ${res.monthlyPriceJpy}/month (Japanese yen)`],
5181
+ ["Checkout URL", res.checkoutUrl],
5182
+ ["Final confirmation", "Stripe-hosted checkout page"]
5183
+ ],
5184
+ steps: [
5185
+ "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."
5186
+ ],
5187
+ notes: [
5188
+ "Once Stripe confirms payment and Sakupa synchronizes the subscription, the current URL stays live while that subscription remains active.",
5189
+ "Binding a custom domain (bind) is optional and still requires DNS verification."
5190
+ ],
5191
+ next: ["`billing` after the user completes checkout"]
5192
+ }),
4978
5193
  {
4979
5194
  siteId: res.siteId,
4980
5195
  plan: res.plan,
@@ -4993,6 +5208,7 @@ Once Stripe confirms payment and Sakupa synchronizes the subscription, the curre
4993
5208
  server.registerTool(
4994
5209
  "bind",
4995
5210
  {
5211
+ title: "Bind custom domain",
4996
5212
  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.`,
4997
5213
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4998
5214
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -5027,14 +5243,25 @@ Once Stripe confirms payment and Sakupa synchronizes the subscription, the curre
5027
5243
  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}.`;
5028
5244
  return text(
5029
5245
  res2.bindingStatus === "active" ? "domain_binding_active" : res2.bindingStatus === "provisioning" ? "domain_binding_provisioning" : "domain_verification_pending",
5030
- `Domain binding status for ${apex2}: ${res2.status}
5031
- ${res2.message}
5032
- The serving CNAME www.${apex2} \u2192 ${res2.servingTarget} must remain for as long as this domain is bound.` + (manualProviderRecheckRequired ? `
5033
-
5034
- ${customerRecheckInstruction}` : "") + "\n\n" + renderChecklistBlock(
5035
- diag,
5036
- "Fix any [MISSING]/[FIX] lines above, then re-run bind status; if still failing after the attempts below, show the user this checklist."
5037
- ),
5246
+ summaryMarkdown({
5247
+ title: `Domain binding status for ${apex2}: ${res2.status}`,
5248
+ lead: res2.message,
5249
+ facts: [
5250
+ ["Ownership verification", res2.status],
5251
+ ["Binding status", res2.bindingStatus],
5252
+ ["Provisioning phase", res2.provisioningPhase],
5253
+ ["Required serving record", `www.${apex2} CNAME \u2192 ${res2.servingTarget}`],
5254
+ ["Live for the customer", res2.bindingStatus === "active" ? "yes" : "not yet"]
5255
+ ],
5256
+ notes: [
5257
+ `The serving CNAME www.${apex2} \u2192 ${res2.servingTarget} must remain for as long as this domain is bound.`,
5258
+ ...manualProviderRecheckRequired ? [customerRecheckInstruction] : []
5259
+ ],
5260
+ raw: renderChecklistBlock(
5261
+ diag,
5262
+ "Fix any [MISSING]/[FIX] lines above, then re-run bind status; if still failing after the attempts below, show the user this checklist."
5263
+ )
5264
+ }),
5038
5265
  {
5039
5266
  verificationId: res2.verificationId,
5040
5267
  status: res2.status,
@@ -5089,17 +5316,28 @@ ${customerRecheckInstruction}` : "") + "\n\n" + renderChecklistBlock(
5089
5316
  ` : "";
5090
5317
  return text(
5091
5318
  "domain_verification_started",
5092
- switchNotice + `Domain binding started for ${apex} (routes reserved: ${res.includedHostnames.join(", ")}). Only www.${apex} is required to go live; the naked domain is optional.
5093
-
5094
- 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.
5095
-
5096
- STEP 1 of 2 \u2014 prove ownership. Add ONE record:
5097
-
5098
- TXT host: ${txtShort} value: ${res.verificationRecord.value}
5099
-
5100
- 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.
5101
-
5102
- 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.`,
5319
+ summaryMarkdown({
5320
+ title: `Domain binding started for ${apex}`,
5321
+ 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.`,
5322
+ facts: [
5323
+ ["STEP 1 of 2 \u2014 record type", "TXT"],
5324
+ ["TXT host (short form)", txtShort],
5325
+ ["TXT value", res.verificationRecord.value],
5326
+ ["Full record name", res.verificationRecord.name],
5327
+ ["Challenge expires", "after 72 hours"]
5328
+ ],
5329
+ steps: [
5330
+ `STEP 1 of 2 \u2014 prove ownership. Add ONE record: TXT host: ${txtShort} value: ${res.verificationRecord.value}`,
5331
+ '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.'
5332
+ ],
5333
+ notes: [
5334
+ `Host is the SHORT form: most panels append the domain automatically (the saved record must NOT show ${apex} twice in one name).`,
5335
+ "Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins.",
5336
+ "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.",
5337
+ '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.'
5338
+ ],
5339
+ next: ['`bind` with action "status" after the user reports the TXT record is set']
5340
+ }),
5103
5341
  {
5104
5342
  verificationId: res.verificationId,
5105
5343
  apexDomain: apex,
@@ -5132,6 +5370,7 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
5132
5370
  server.registerTool(
5133
5371
  "billing",
5134
5372
  {
5373
+ title: "Billing snapshot",
5135
5374
  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).",
5136
5375
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5137
5376
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -5165,9 +5404,14 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
5165
5404
  res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
5166
5405
  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
5167
5406
  ].filter((l) => l !== void 0);
5168
- return textJson("billing_returned", `${lines.join("\n")}
5407
+ return textJson(
5408
+ "billing_returned",
5409
+ `AUTHORITATIVE BILLING SNAPSHOT for site ${res.siteId} (mode: ${res.mode})`,
5410
+ `${lines.slice(1).map((line) => `- ${line}`).join("\n")}
5169
5411
 
5170
- Full status:`, res);
5412
+ Full status:`,
5413
+ res
5414
+ );
5171
5415
  } catch (e) {
5172
5416
  return toolError(e);
5173
5417
  }
@@ -5176,6 +5420,7 @@ Full status:`, res);
5176
5420
  server.registerTool(
5177
5421
  "portal",
5178
5422
  {
5423
+ title: "Billing portal (Stripe)",
5179
5424
  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.",
5180
5425
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5181
5426
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -5193,7 +5438,12 @@ Full status:`, res);
5193
5438
  schemaVersion: 1,
5194
5439
  outcome: "waiting_user",
5195
5440
  resultCode: "site_billing_portal_ready",
5196
- summary: `Short-lived Stripe customer portal link created for this site: ${res2.portalUrl}. Any change still happens only on the Stripe-hosted page.`,
5441
+ summary: summaryMarkdown({
5442
+ title: "Stripe customer portal link ready",
5443
+ lead: `Short-lived Stripe customer portal link created for this site: ${res2.portalUrl}`,
5444
+ notes: ["Any change still happens only on the Stripe-hosted page."],
5445
+ next: ["`billing` after the user finishes on Stripe"]
5446
+ }),
5197
5447
  data: { scope: args.scope, portalUrl: res2.portalUrl },
5198
5448
  userAction: {
5199
5449
  type: "open_url",
@@ -5209,7 +5459,14 @@ Full status:`, res);
5209
5459
  schemaVersion: 1,
5210
5460
  outcome: "waiting_user",
5211
5461
  resultCode: "public_billing_recovery_portal_ready",
5212
- 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.`,
5462
+ summary: summaryMarkdown({
5463
+ title: "Stripe public billing login page",
5464
+ lead: `Stripe public email-OTP login page: ${res.portalUrl}`,
5465
+ notes: [
5466
+ "It uses a one-time passcode, does not recover the Sakupa key, and grants no site authority.",
5467
+ "When one email has several Customers, Stripe may open only the most recently created usable record."
5468
+ ]
5469
+ }),
5213
5470
  data: {
5214
5471
  scope: args.scope,
5215
5472
  portalUrl: res.portalUrl,
@@ -5232,6 +5489,7 @@ Full status:`, res);
5232
5489
  server.registerTool(
5233
5490
  "recover",
5234
5491
  {
5492
+ title: "Recover site",
5235
5493
  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.",
5236
5494
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5237
5495
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -5245,7 +5503,7 @@ Full status:`, res);
5245
5503
  preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
5246
5504
  })
5247
5505
  },
5248
- async (args, call) => {
5506
+ withDecisionReentry("recover", async (args, call) => {
5249
5507
  try {
5250
5508
  const ctx = await withProjectDir(baseCtx, call);
5251
5509
  if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
@@ -5453,7 +5711,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
5453
5711
  ...revokeArguments,
5454
5712
  preserveExistingCredentials: true
5455
5713
  };
5456
- return decisionToolResult({
5714
+ return presentDecision(baseCtx.decisions, call, "recover", {
5457
5715
  resultCode: "domain_recovery_ready",
5458
5716
  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.",
5459
5717
  data: {
@@ -5588,11 +5846,12 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5588
5846
  } catch (e) {
5589
5847
  return toolError(e);
5590
5848
  }
5591
- }
5849
+ })
5592
5850
  );
5593
5851
  server.registerTool(
5594
5852
  "support",
5595
5853
  {
5854
+ title: "Support ticket",
5596
5855
  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.",
5597
5856
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5598
5857
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -5616,7 +5875,14 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5616
5875
  });
5617
5876
  return text(
5618
5877
  "support_ticket_created",
5619
- `Support ticket created: ${res.ticketId} (status: ${res.status}).`,
5878
+ summaryMarkdown({
5879
+ title: "Support ticket created",
5880
+ facts: [
5881
+ ["Ticket", res.ticketId],
5882
+ ["Status", res.status]
5883
+ ],
5884
+ notes: ["Wait for the Sakupa support follow-up; no further tool call is needed."]
5885
+ }),
5620
5886
  { ticketId: res.ticketId, status: res.status }
5621
5887
  );
5622
5888
  } catch (e) {
@@ -5627,6 +5893,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5627
5893
  server.registerTool(
5628
5894
  "report",
5629
5895
  {
5896
+ title: "Bug report",
5630
5897
  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.",
5631
5898
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5632
5899
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -5648,7 +5915,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5648
5915
  confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
5649
5916
  })
5650
5917
  },
5651
- async (args, call) => {
5918
+ withDecisionReentry("report", async (args, call) => {
5652
5919
  try {
5653
5920
  requireReportAuthorization(baseCtx, args.helpAuthorization, args.toolName);
5654
5921
  const ctx = await optionalProjectContext(baseCtx, call);
@@ -5678,7 +5945,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5678
5945
  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). ";
5679
5946
  const confirmation = { confirmSubmit: true };
5680
5947
  const confirmArguments = { ...args, ...confirmation };
5681
- return decisionToolResult({
5948
+ return presentDecision(baseCtx.decisions, call, "report", {
5682
5949
  resultCode: "bug_report_preview_ready",
5683
5950
  outcome: "preview",
5684
5951
  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:
@@ -5720,7 +5987,7 @@ Summary: ${res.sanitizedSummary}`,
5720
5987
  } catch (e) {
5721
5988
  return toolError(e);
5722
5989
  }
5723
- }
5990
+ })
5724
5991
  );
5725
5992
  }
5726
5993
 
@@ -5728,8 +5995,10 @@ Summary: ${res.sanitizedSummary}`,
5728
5995
  import {
5729
5996
  CLIENT_CAPABILITIES_META_KEY,
5730
5997
  McpServer,
5998
+ createRequestStateCodec,
5731
5999
  inputResponse
5732
6000
  } from "@modelcontextprotocol/server";
6001
+ import { randomBytes as randomBytes2 } from "node:crypto";
5733
6002
 
5734
6003
  // src/tools/billing.ts
5735
6004
  import { z as z3 } from "zod";
@@ -5737,6 +6006,7 @@ function registerBillingTools(server, baseCtx) {
5737
6006
  server.registerTool(
5738
6007
  "plans",
5739
6008
  {
6009
+ title: "Hosting plan catalog",
5740
6010
  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.",
5741
6011
  inputSchema: z3.object({}),
5742
6012
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -5749,7 +6019,21 @@ function registerBillingTools(server, baseCtx) {
5749
6019
  schemaVersion: 1,
5750
6020
  outcome: "completed",
5751
6021
  resultCode: "billing_catalog_returned",
5752
- summary: `Returned ${catalog.plans.length} monthly plans; the Stripe-hosted page is the final confirmation surface for payment and plan changes.`,
6022
+ summary: summaryMarkdown({
6023
+ title: `Sakupa monthly plans (catalog ${catalog.catalogVersion})`,
6024
+ 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).`,
6025
+ raw: [
6026
+ "| Plan | Rank | JPY / month | Storage (bytes) | Transfer (bytes) | Requests |",
6027
+ "|---|---|---|---|---|---|",
6028
+ ...catalog.plans.map(
6029
+ (plan) => `| ${plan.id} | ${plan.rank} | ${plan.monthlyPriceJpy} | ${plan.limits.storageBytes} | ${plan.limits.transferBytes} | ${plan.limits.requests} |`
6030
+ )
6031
+ ].join("\n"),
6032
+ notes: [
6033
+ `Upgrades bill immediately at full price (${catalog.upgradeChargeTiming}); downgrades take effect at ${catalog.downgradeEffectiveTiming}; unused transfer carries once (${catalog.upgradeTransferCarry}).`
6034
+ ],
6035
+ next: ["`subscribe` for a first subscription", "`change` for an existing subscription"]
6036
+ }),
5753
6037
  data: { catalog },
5754
6038
  nextActions: [{ tool: "subscribe", allowed: true }]
5755
6039
  });
@@ -5761,6 +6045,7 @@ function registerBillingTools(server, baseCtx) {
5761
6045
  server.registerTool(
5762
6046
  "change",
5763
6047
  {
6048
+ title: "Change subscription (Stripe)",
5764
6049
  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.",
5765
6050
  inputSchema: z3.object({
5766
6051
  operationId: z3.string().min(1)
@@ -5781,7 +6066,25 @@ function registerBillingTools(server, baseCtx) {
5781
6066
  outcome: "waiting_user",
5782
6067
  resultCode: "stripe_subscription_management_required",
5783
6068
  operationId: args.operationId,
5784
- 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.`,
6069
+ summary: summaryMarkdown({
6070
+ title: "Stripe subscription-management link ready",
6071
+ lead: `Stripe subscription-management link (present this exact URL to the user): ${result.portalUrl}`,
6072
+ facts: [
6073
+ ["Subscription changed", "NO \u2014 nothing changes until the user confirms on Stripe"],
6074
+ [
6075
+ "Plan order (lowest \u2192 highest)",
6076
+ Array.isArray(result.planOrder) ? result.planOrder.join(" \u2192 ") : void 0
6077
+ ]
6078
+ ],
6079
+ steps: [
6080
+ "Open the link and choose Water, Personal, Share, Business, or period-end cancellation on the Stripe-hosted page."
6081
+ ],
6082
+ notes: [
6083
+ "After Stripe confirmation, upgrades start a new billing cycle immediately at full price; downgrades and cancellation take effect at the current period end.",
6084
+ "The authoritative plan order from lowest to highest is Water, Personal, Share, Business; never describe a lower-ranked plan as an upgrade."
6085
+ ],
6086
+ next: ["`billing` after the user finishes on Stripe"]
6087
+ }),
5785
6088
  data: { portalUrl: result.portalUrl, result },
5786
6089
  userAction: {
5787
6090
  type: "open_url",
@@ -6052,6 +6355,7 @@ function registerHelpTools(server, baseCtx) {
6052
6355
  server.registerTool(
6053
6356
  "init",
6054
6357
  {
6358
+ title: "Initialize project",
6055
6359
  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.",
6056
6360
  inputSchema: z4.object({}),
6057
6361
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -6070,7 +6374,16 @@ function registerHelpTools(server, baseCtx) {
6070
6374
  schemaVersion: 1,
6071
6375
  outcome: "completed",
6072
6376
  resultCode: "project_initialized",
6073
- 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.`,
6377
+ summary: summaryMarkdown({
6378
+ title: "Sakupa project initialized",
6379
+ lead: `Initialized and verified at the active workspace Root: ${ctx.projectDir}. No cloud site was created and no charge occurred.`,
6380
+ facts: [
6381
+ ["Project root", ctx.projectDir],
6382
+ [".sakupa directory", sakupaDirectory],
6383
+ ["Binding source", ctx.bindingSource]
6384
+ ],
6385
+ next: ["`analyze`, then `deploy` with the exact relative outputDir"]
6386
+ }),
6074
6387
  data: {
6075
6388
  projectRoot: ctx.projectDir,
6076
6389
  sakupaDirectory,
@@ -6092,6 +6405,7 @@ function registerHelpTools(server, baseCtx) {
6092
6405
  server.registerTool(
6093
6406
  "help",
6094
6407
  {
6408
+ title: "Help and diagnosis",
6095
6409
  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.",
6096
6410
  inputSchema: z4.object({
6097
6411
  topic: z4.enum(HELP_TOPICS).optional().default("diagnose"),
@@ -6119,7 +6433,25 @@ function registerHelpTools(server, baseCtx) {
6119
6433
  schemaVersion: 1,
6120
6434
  outcome: "completed",
6121
6435
  resultCode: "help_overview",
6122
- 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.`,
6436
+ summary: summaryMarkdown({
6437
+ title: "Sakupa tool overview",
6438
+ lead: "Sakupa tool overview and parameter names returned.",
6439
+ raw: [
6440
+ "| Tool | Purpose | Parameters |",
6441
+ "|---|---|---|",
6442
+ ...TOOL_TOPICS.map(
6443
+ (tool) => `| \`${tool}\` | ${TOOL_MANUALS[tool].purpose} | ${TOOL_MANUALS[tool].parameterNames.join(", ") || "\u2014"} |`
6444
+ )
6445
+ ].join("\n"),
6446
+ notes: [
6447
+ "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.",
6448
+ "When a result contains decision, present every option, select none by default, and copy only the user's selected option nextAction exactly.",
6449
+ 'Use help topic:"terminology" for every site/credential distinction.'
6450
+ ],
6451
+ next: [
6452
+ '`help` with topic:"diagnose" on any failure \u2014 before retrying, support or report'
6453
+ ]
6454
+ }),
6123
6455
  data: {
6124
6456
  tools: catalog,
6125
6457
  toolOrder: TOOL_TOPICS,
@@ -6154,13 +6486,20 @@ function registerHelpTools(server, baseCtx) {
6154
6486
  schemaVersion: 1,
6155
6487
  outcome: "completed",
6156
6488
  resultCode: "help_tool_manual",
6157
- summary: `${args.topic}: ${manual.purpose}
6158
- Side effects: ${manual.sideEffects}
6159
- Preconditions: ${manual.preconditions}
6160
- Parameters: ${manual.parameters}
6161
- Warnings: ${manual.warnings.join(" ")}
6162
- Next: ${manual.nextStep}` + (terminologyText.length > 0 ? `
6163
- Terminology: ${terminologyText}` : ""),
6489
+ summary: summaryMarkdown({
6490
+ title: `${args.topic}: ${manual.purpose}`,
6491
+ facts: [
6492
+ ["Side effects", manual.sideEffects],
6493
+ ["Preconditions", manual.preconditions],
6494
+ ["Parameters", manual.parameters],
6495
+ ["Parameter names", manual.parameterNames.join(", ") || "(none)"]
6496
+ ],
6497
+ notes: [
6498
+ ...manual.warnings,
6499
+ ...terminologyText.length > 0 ? [`Terminology: ${terminologyText}`] : []
6500
+ ],
6501
+ next: [manual.nextStep]
6502
+ }),
6164
6503
  data: { tool: args.topic, ...manual, relatedTerminology },
6165
6504
  nextActions: []
6166
6505
  });
@@ -6208,7 +6547,23 @@ Terminology: ${terminologyText}` : ""),
6208
6547
  }
6209
6548
  ] : credentialRotationState === "pending" ? [{ tool: "rotate", allowed: true, reasonCode: "resume_confirmed_rotation" }] : diagnosis.diagnosisCode === "workspace_not_initialized" ? [{ tool: "init", allowed: true, reasonCode: "initialize_active_root" }] : [];
6210
6549
  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." : "";
6211
- 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.");
6550
+ const summary = summaryMarkdown({
6551
+ title: `Help diagnosis: ${diagnosis.diagnosisCode}`,
6552
+ lead: diagnosis.guidance,
6553
+ facts: [
6554
+ ["MCP version", MCP_VERSION],
6555
+ ["Project marker", marker.kind],
6556
+ ["Site binding", site.kind],
6557
+ ["Recovery state", recoveryState],
6558
+ ["Credential rotation", credentialRotationState],
6559
+ ["Report recommended", reportRecommended ? "yes (last resort)" : "no"]
6560
+ ],
6561
+ notes: [
6562
+ ...rotationGuidance ? [rotationGuidance] : [],
6563
+ 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."
6564
+ ],
6565
+ next: nextActions.map((action) => `\`${action.tool}\` (${action.reasonCode})`)
6566
+ });
6212
6567
  return structuredToolResult({
6213
6568
  schemaVersion: 1,
6214
6569
  outcome: diagnosis.diagnosisCode === "project_bound" ? "completed" : "blocked",
@@ -6243,6 +6598,7 @@ function registerCredentialTools(server, baseCtx) {
6243
6598
  server.registerTool(
6244
6599
  "rotate",
6245
6600
  {
6601
+ title: "Rotate site credential",
6246
6602
  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.",
6247
6603
  inputSchema: z5.object({
6248
6604
  confirmed: z5.boolean().optional().describe(
@@ -6252,7 +6608,7 @@ function registerCredentialTools(server, baseCtx) {
6252
6608
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
6253
6609
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
6254
6610
  },
6255
- async (args, call) => {
6611
+ withDecisionReentry("rotate", async (args, call) => {
6256
6612
  let releaseLock;
6257
6613
  try {
6258
6614
  const ctx = await withProjectDir(baseCtx, call);
@@ -6274,7 +6630,19 @@ function registerCredentialTools(server, baseCtx) {
6274
6630
  schemaVersion: 1,
6275
6631
  outcome: "completed",
6276
6632
  resultCode: "credential_rotation_resumed",
6277
- 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.`,
6633
+ summary: summaryMarkdown({
6634
+ title: `Credential rotation resumed and completed for ${site.url ?? site.siteId}`,
6635
+ facts: [
6636
+ ["Site", site.url ?? site.siteId],
6637
+ ["New credential stored at", ".sakupa/site.json (this project only)"],
6638
+ ["Previous credentials", "all revoked"]
6639
+ ],
6640
+ notes: [
6641
+ "Every previous credential is revoked; old project folders and backup copies can no longer manage this site.",
6642
+ "No credential value is shown."
6643
+ ],
6644
+ next: ["`status`"]
6645
+ }),
6278
6646
  data: {
6279
6647
  siteId: site.siteId,
6280
6648
  credentialCreatedAt: resumed.status.credentialCreatedAt,
@@ -6289,9 +6657,20 @@ function registerCredentialTools(server, baseCtx) {
6289
6657
  const status = await ctx.client.getCredentialStatus(site.siteId, site.credential);
6290
6658
  const confirmation = { confirmed: true };
6291
6659
  if (args.confirmed !== true) {
6292
- return decisionToolResult({
6660
+ return presentDecision(baseCtx.decisions, call, "rotate", {
6293
6661
  resultCode: "credential_rotation_confirmation_required",
6294
- 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.`,
6662
+ summary: summaryMarkdown({
6663
+ title: `Rotate the management credential for ${site.url ?? site.siteId}? Nothing was changed.`,
6664
+ facts: [
6665
+ ["Current credential created at", timestampForAgent(status.credentialCreatedAt)],
6666
+ ["Rotation", "optional; deploy remains available"],
6667
+ ["Exact confirm arguments", JSON.stringify(confirmation)]
6668
+ ],
6669
+ notes: [
6670
+ "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.",
6671
+ "Ask the user for explicit approval; never expose credential values."
6672
+ ]
6673
+ }),
6295
6674
  data: {
6296
6675
  siteId: site.siteId,
6297
6676
  credentialCreatedAt: status.credentialCreatedAt,
@@ -6342,7 +6721,21 @@ function registerCredentialTools(server, baseCtx) {
6342
6721
  schemaVersion: 1,
6343
6722
  outcome: "completed",
6344
6723
  resultCode: "credential_rotated",
6345
- 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.`,
6724
+ summary: summaryMarkdown({
6725
+ title: `Management credential rotated for ${site.url ?? site.siteId}`,
6726
+ facts: [
6727
+ ["New credential stored at", ".sakupa/site.json (this project only)"],
6728
+ [
6729
+ "Previous credentials revoked",
6730
+ completed.rotation?.revokedPreviousCredentials ?? "all"
6731
+ ]
6732
+ ],
6733
+ notes: [
6734
+ "Every previous credential is revoked; old project folders and backup copies can no longer manage this site.",
6735
+ "No credential value is shown."
6736
+ ],
6737
+ next: ["`status`"]
6738
+ }),
6346
6739
  data: {
6347
6740
  siteId: site.siteId,
6348
6741
  credentialCreatedAt: completed.status.credentialCreatedAt,
@@ -6359,7 +6752,7 @@ function registerCredentialTools(server, baseCtx) {
6359
6752
  } finally {
6360
6753
  releaseLock?.();
6361
6754
  }
6362
- }
6755
+ })
6363
6756
  );
6364
6757
  }
6365
6758
 
@@ -6488,13 +6881,39 @@ Safety boundaries:
6488
6881
  a bound custom domain, a lost credential is unrecoverable by design. portal then opens
6489
6882
  Stripe's public no-code portal login, where the customer verifies the checkout email with a
6490
6883
  Stripe one-time passcode; it never restores site authority.`;
6884
+ var DECISION_ROUND_TIMEOUT_MS = 12e4;
6885
+ var DECISION_STATE_TTL_SECONDS = 900;
6886
+ function clientSupportsFormElicitation(server, call) {
6887
+ let declared;
6888
+ if (call?.mcpReq.envelope !== void 0) {
6889
+ const envelope = call.mcpReq.envelope;
6890
+ declared = envelope[CLIENT_CAPABILITIES_META_KEY];
6891
+ } else {
6892
+ declared = server.server.getClientCapabilities();
6893
+ }
6894
+ const elicitation = declared?.elicitation;
6895
+ if (!elicitation || typeof elicitation !== "object") return false;
6896
+ if (elicitation.form !== void 0) return true;
6897
+ return elicitation.url === void 0;
6898
+ }
6491
6899
  function createSakupaMcpServer(opts) {
6492
6900
  const client = opts.client ?? new HttpApiClient(
6493
6901
  new FetchTransport(opts.apiBaseUrl, { testAccessToken: opts.testAccessToken })
6494
6902
  );
6903
+ const decisionCodec = createRequestStateCodec({
6904
+ key: randomBytes2(32),
6905
+ ttlSeconds: DECISION_STATE_TTL_SECONDS
6906
+ });
6495
6907
  const server = new McpServer(
6496
6908
  { name: "sakupa", version: MCP_VERSION },
6497
- { instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)) }
6909
+ {
6910
+ instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)),
6911
+ // A native decision prompt must resolve well inside common IDE tool
6912
+ // deadlines; past this the legacy shim fails the round and the tool
6913
+ // falls back to the text decision on the next call.
6914
+ inputRequired: { roundTimeoutMs: DECISION_ROUND_TIMEOUT_MS },
6915
+ requestState: { verify: (state, call) => decisionCodec.verify(state, call) }
6916
+ }
6498
6917
  );
6499
6918
  const processCwd = resolve6(opts.projectDir ?? process.cwd());
6500
6919
  const rootsProvider = opts.rootsProvider ?? ((call) => readClientRoots(server, call));
@@ -6509,7 +6928,11 @@ function createSakupaMcpServer(opts) {
6509
6928
  rootsProvider,
6510
6929
  MCP_ROOTS_TIMEOUT_MS,
6511
6930
  opts.projectRoot
6512
- )
6931
+ ),
6932
+ decisions: {
6933
+ supportsFormElicitation: (call) => clientSupportsFormElicitation(server, call),
6934
+ codec: decisionCodec
6935
+ }
6513
6936
  };
6514
6937
  registerTools(server, ctx);
6515
6938
  registerBillingTools(server, ctx);