@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.
- package/dist/bin.js +529 -106
- package/dist/index.js +529 -106
- 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.
|
|
405
|
+
var SAKUPA_MCP_VERSION = "1.3.0";
|
|
406
406
|
|
|
407
407
|
// ../core/dist/domain/errors.js
|
|
408
408
|
var HTTP_STATUS = {
|
|
@@ -770,8 +770,10 @@ function environmentFor(apiBaseUrl) {
|
|
|
770
770
|
import {
|
|
771
771
|
CLIENT_CAPABILITIES_META_KEY,
|
|
772
772
|
McpServer,
|
|
773
|
+
createRequestStateCodec,
|
|
773
774
|
inputResponse
|
|
774
775
|
} from "@modelcontextprotocol/server";
|
|
776
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
775
777
|
|
|
776
778
|
// src/api-client.ts
|
|
777
779
|
var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
@@ -3411,10 +3413,51 @@ function structuredToolResult(envelope) {
|
|
|
3411
3413
|
return {
|
|
3412
3414
|
content: [{ type: "text", text: `${envelope.summary}
|
|
3413
3415
|
|
|
3416
|
+
---
|
|
3414
3417
|
${presentationFallback}` }],
|
|
3415
3418
|
structuredContent: structuredEnvelope
|
|
3416
3419
|
};
|
|
3417
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
|
+
}
|
|
3418
3461
|
function timestampForAgent(exactTimestamp) {
|
|
3419
3462
|
return timestampForAgentInZone(exactTimestamp, clientRuntimeTimeZone());
|
|
3420
3463
|
}
|
|
@@ -3608,8 +3651,14 @@ function toolError(e) {
|
|
|
3608
3651
|
const serverGuidance = isSakupaError(e) && errorCode !== "internal" && errorCode !== "unauthorized" && errorCode !== "upgrade_required" && e.message.trim().length > 0 ? e.message : void 0;
|
|
3609
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."));
|
|
3610
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.";
|
|
3611
|
-
const userFacingSummary =
|
|
3612
|
-
|
|
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
|
+
});
|
|
3613
3662
|
const result = structuredToolResult({
|
|
3614
3663
|
schemaVersion: 1,
|
|
3615
3664
|
outcome: "failed",
|
|
@@ -3638,6 +3687,7 @@ Technical context for the AI: ${safeSummary}`;
|
|
|
3638
3687
|
}
|
|
3639
3688
|
|
|
3640
3689
|
// src/tools/decision.ts
|
|
3690
|
+
import { acceptedContent, inputRequired as inputRequired2 } from "@modelcontextprotocol/server";
|
|
3641
3691
|
var DECISION_PRESENTATION_POLICY = {
|
|
3642
3692
|
translateFields: [
|
|
3643
3693
|
"decision.prompt",
|
|
@@ -3730,11 +3780,14 @@ function buildDecisionContract(prompt, options) {
|
|
|
3730
3780
|
function formatDecisionFallback(decision) {
|
|
3731
3781
|
const options = decision.options.map((option, index) => {
|
|
3732
3782
|
const consequences = option.consequences.length === 0 ? "" : `
|
|
3733
|
-
Consequences: ${option.consequences.join(" ")}`;
|
|
3783
|
+
- Consequences: ${option.consequences.join(" ")}`;
|
|
3734
3784
|
let exactAction;
|
|
3735
3785
|
switch (option.nextAction.type) {
|
|
3736
3786
|
case "call_tool":
|
|
3737
|
-
exactAction = `If the user selects this option, call
|
|
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
|
+
\`\`\``;
|
|
3738
3791
|
break;
|
|
3739
3792
|
case "open_url":
|
|
3740
3793
|
exactAction = `If the user selects this option, present this exact URL: ${option.nextAction.url}.`;
|
|
@@ -3743,11 +3796,13 @@ function formatDecisionFallback(decision) {
|
|
|
3743
3796
|
exactAction = "If the user selects this option, call no tool and make no change.";
|
|
3744
3797
|
break;
|
|
3745
3798
|
}
|
|
3746
|
-
return `${index + 1}. [${option.id}]
|
|
3799
|
+
return `${index + 1}. [${option.id}] **${option.label}**
|
|
3747
3800
|
${option.description}${consequences}
|
|
3748
3801
|
${exactAction}`;
|
|
3749
3802
|
});
|
|
3750
|
-
return
|
|
3803
|
+
return `### USER DECISION REQUIRED
|
|
3804
|
+
${decision.prompt}
|
|
3805
|
+
|
|
3751
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.
|
|
3752
3807
|
|
|
3753
3808
|
` + options.join("\n\n");
|
|
@@ -3827,6 +3882,76 @@ function noActionDecisionOption(input) {
|
|
|
3827
3882
|
nextAction: { type: "none" }
|
|
3828
3883
|
};
|
|
3829
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
|
+
}
|
|
3830
3955
|
|
|
3831
3956
|
// src/tools/definitions.ts
|
|
3832
3957
|
function text(resultCode, t, data = {}, outcome = "completed", nextActions = []) {
|
|
@@ -3839,9 +3964,14 @@ function text(resultCode, t, data = {}, outcome = "completed", nextActions = [])
|
|
|
3839
3964
|
nextActions
|
|
3840
3965
|
});
|
|
3841
3966
|
}
|
|
3842
|
-
function textJson(resultCode,
|
|
3843
|
-
const summary =
|
|
3844
|
-
|
|
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
|
+
});
|
|
3845
3975
|
return structuredToolResult({
|
|
3846
3976
|
schemaVersion: 1,
|
|
3847
3977
|
outcome,
|
|
@@ -3884,7 +4014,8 @@ function analysisSummary(analysis) {
|
|
|
3884
4014
|
function notDeployableResult(analysis) {
|
|
3885
4015
|
return textJson(
|
|
3886
4016
|
"site_analysis_not_deployable",
|
|
3887
|
-
|
|
4017
|
+
"This project is NOT deployable as-is",
|
|
4018
|
+
`No files were uploaded and no API call was made.
|
|
3888
4019
|
Next action: ${analysis.suggestedNextAction}
|
|
3889
4020
|
Analysis:`,
|
|
3890
4021
|
analysisSummary(analysis),
|
|
@@ -3975,14 +4106,22 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
|
|
|
3975
4106
|
};
|
|
3976
4107
|
}
|
|
3977
4108
|
}
|
|
3978
|
-
function freeSiteCreationBarrier(sites, deployArguments, allowanceNetworkReference) {
|
|
3979
|
-
const
|
|
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:
|
|
3980
4113
|
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
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", {
|
|
3986
4125
|
resultCode: "free_site_slot_selection_required",
|
|
3987
4126
|
summary,
|
|
3988
4127
|
data: {
|
|
@@ -4088,6 +4227,7 @@ function registerTools(server, baseCtx) {
|
|
|
4088
4227
|
server.registerTool(
|
|
4089
4228
|
"analyze",
|
|
4090
4229
|
{
|
|
4230
|
+
title: "Analyze project",
|
|
4091
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.",
|
|
4092
4232
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4093
4233
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
@@ -4103,8 +4243,8 @@ function registerTools(server, baseCtx) {
|
|
|
4103
4243
|
});
|
|
4104
4244
|
return textJson(
|
|
4105
4245
|
"site_analysis_completed",
|
|
4106
|
-
`Analysis of ${ctx.projectDir}
|
|
4107
|
-
Next action: ${analysis.suggestedNextAction}`,
|
|
4246
|
+
`Analysis of ${ctx.projectDir}`,
|
|
4247
|
+
`Next action: ${analysis.suggestedNextAction}`,
|
|
4108
4248
|
analysisSummary(analysis)
|
|
4109
4249
|
);
|
|
4110
4250
|
} catch (e) {
|
|
@@ -4115,6 +4255,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4115
4255
|
server.registerTool(
|
|
4116
4256
|
"deploy",
|
|
4117
4257
|
{
|
|
4258
|
+
title: "Deploy site",
|
|
4118
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.`,
|
|
4119
4260
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4120
4261
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
@@ -4146,7 +4287,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4146
4287
|
lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
4147
4288
|
})
|
|
4148
4289
|
},
|
|
4149
|
-
async (args, call) => {
|
|
4290
|
+
withDecisionReentry("deploy", async (args, call) => {
|
|
4150
4291
|
let releaseHandoffLock;
|
|
4151
4292
|
try {
|
|
4152
4293
|
const ctx = await withProjectDir(baseCtx, call);
|
|
@@ -4163,7 +4304,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4163
4304
|
outputDir: effectiveOutputDir,
|
|
4164
4305
|
...confirmation
|
|
4165
4306
|
};
|
|
4166
|
-
return
|
|
4307
|
+
return presentDecision(baseCtx.decisions, call, "deploy", {
|
|
4167
4308
|
resultCode: "publish_directory_change_confirmation_required",
|
|
4168
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.`,
|
|
4169
4310
|
data: {
|
|
@@ -4246,7 +4387,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4246
4387
|
if (args.sakupaRelocationConfirmed !== true) {
|
|
4247
4388
|
const confirmation = { sakupaRelocationConfirmed: true };
|
|
4248
4389
|
const confirmArguments = { ...args, ...confirmation };
|
|
4249
|
-
return
|
|
4390
|
+
return presentDecision(baseCtx.decisions, call, "deploy", {
|
|
4250
4391
|
resultCode: "sakupa_relocation_confirmation_required",
|
|
4251
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.`,
|
|
4252
4393
|
data: {
|
|
@@ -4399,7 +4540,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4399
4540
|
if (!existing && args.reuseSiteUrl === void 0 && args.publicConfirmed !== true) {
|
|
4400
4541
|
const confirmation = { publicConfirmed: true };
|
|
4401
4542
|
const confirmArguments = { ...args, ...confirmation };
|
|
4402
|
-
return
|
|
4543
|
+
return presentDecision(baseCtx.decisions, call, "deploy", {
|
|
4403
4544
|
resultCode: "public_deployment_confirmation_required",
|
|
4404
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.`,
|
|
4405
4546
|
data: {
|
|
@@ -4431,7 +4572,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4431
4572
|
if (args.reuseConfirmed !== true) {
|
|
4432
4573
|
const confirmation = { reuseConfirmed: true };
|
|
4433
4574
|
const confirmArguments = { ...args, publicConfirmed: true, ...confirmation };
|
|
4434
|
-
return
|
|
4575
|
+
return presentDecision(baseCtx.decisions, call, "deploy", {
|
|
4435
4576
|
resultCode: "free_site_reuse_confirmation_required",
|
|
4436
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.`,
|
|
4437
4578
|
data: {
|
|
@@ -4563,7 +4704,13 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4563
4704
|
if (isSakupaError(error) && error.code === "rate_limited") {
|
|
4564
4705
|
const allowanceNetworkReference = allowanceNetworkReferenceFrom(error);
|
|
4565
4706
|
if (deviceSites.length > 0) {
|
|
4566
|
-
return freeSiteCreationBarrier(
|
|
4707
|
+
return freeSiteCreationBarrier(
|
|
4708
|
+
baseCtx.decisions,
|
|
4709
|
+
call,
|
|
4710
|
+
deviceSites,
|
|
4711
|
+
{ ...args },
|
|
4712
|
+
allowanceNetworkReference
|
|
4713
|
+
);
|
|
4567
4714
|
}
|
|
4568
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.` : "";
|
|
4569
4716
|
return text(
|
|
@@ -4605,15 +4752,30 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4605
4752
|
});
|
|
4606
4753
|
return text(
|
|
4607
4754
|
"site_published",
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
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
|
+
}),
|
|
4617
4779
|
{
|
|
4618
4780
|
siteId: created.siteId,
|
|
4619
4781
|
shortId: created.shortId,
|
|
@@ -4701,18 +4863,43 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
4701
4863
|
}
|
|
4702
4864
|
return text(
|
|
4703
4865
|
handoffPerformed ? "free_site_slot_reassigned" : "site_updated",
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
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
|
+
}),
|
|
4716
4903
|
{
|
|
4717
4904
|
siteId: existing.siteId,
|
|
4718
4905
|
url: finalized.url,
|
|
@@ -4766,11 +4953,12 @@ Optional security recommendation: this management credential was created at ${ti
|
|
|
4766
4953
|
} finally {
|
|
4767
4954
|
releaseHandoffLock?.();
|
|
4768
4955
|
}
|
|
4769
|
-
}
|
|
4956
|
+
})
|
|
4770
4957
|
);
|
|
4771
4958
|
server.registerTool(
|
|
4772
4959
|
"refresh",
|
|
4773
4960
|
{
|
|
4961
|
+
title: "Refresh free site",
|
|
4774
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.",
|
|
4775
4963
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4776
4964
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -4792,8 +4980,18 @@ Optional security recommendation: this management credential was created at ${ti
|
|
|
4792
4980
|
}
|
|
4793
4981
|
return text(
|
|
4794
4982
|
"site_refreshed",
|
|
4795
|
-
|
|
4796
|
-
|
|
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
|
+
}),
|
|
4797
4995
|
{ siteId: site.siteId, expiresAt: res.expiresAt, projectDir: ctx.projectDir }
|
|
4798
4996
|
);
|
|
4799
4997
|
} catch (e) {
|
|
@@ -4804,6 +5002,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
4804
5002
|
server.registerTool(
|
|
4805
5003
|
"status",
|
|
4806
5004
|
{
|
|
5005
|
+
title: "Site status",
|
|
4807
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.",
|
|
4808
5007
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4809
5008
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -4819,7 +5018,12 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
4819
5018
|
const binding = res.pendingDomainBinding ? await describePendingBinding(ctx.client, site.credential, res.pendingDomainBinding) : void 0;
|
|
4820
5019
|
return textJson(
|
|
4821
5020
|
"status_returned",
|
|
4822
|
-
billing ? `Site status
|
|
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"),
|
|
4823
5027
|
{
|
|
4824
5028
|
...res,
|
|
4825
5029
|
projectDir: ctx.projectDir,
|
|
@@ -4835,6 +5039,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
4835
5039
|
server.registerTool(
|
|
4836
5040
|
"subscribe",
|
|
4837
5041
|
{
|
|
5042
|
+
title: "Subscribe (Stripe Checkout)",
|
|
4838
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.`,
|
|
4839
5044
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4840
5045
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -4858,11 +5063,23 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
4858
5063
|
);
|
|
4859
5064
|
return text(
|
|
4860
5065
|
"subscription_checkout_ready",
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
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
|
+
}),
|
|
4866
5083
|
{
|
|
4867
5084
|
siteId: res.siteId,
|
|
4868
5085
|
plan: res.plan,
|
|
@@ -4881,6 +5098,7 @@ Once Stripe confirms payment and Sakupa synchronizes the subscription, the curre
|
|
|
4881
5098
|
server.registerTool(
|
|
4882
5099
|
"bind",
|
|
4883
5100
|
{
|
|
5101
|
+
title: "Bind custom domain",
|
|
4884
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.`,
|
|
4885
5103
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4886
5104
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
@@ -4915,14 +5133,25 @@ Once Stripe confirms payment and Sakupa synchronizes the subscription, the curre
|
|
|
4915
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}.`;
|
|
4916
5134
|
return text(
|
|
4917
5135
|
res2.bindingStatus === "active" ? "domain_binding_active" : res2.bindingStatus === "provisioning" ? "domain_binding_provisioning" : "domain_verification_pending",
|
|
4918
|
-
|
|
4919
|
-
${res2.
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
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
|
+
}),
|
|
4926
5155
|
{
|
|
4927
5156
|
verificationId: res2.verificationId,
|
|
4928
5157
|
status: res2.status,
|
|
@@ -4977,17 +5206,28 @@ ${customerRecheckInstruction}` : "") + "\n\n" + renderChecklistBlock(
|
|
|
4977
5206
|
` : "";
|
|
4978
5207
|
return text(
|
|
4979
5208
|
"domain_verification_started",
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
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
|
|
4983
|
-
|
|
4984
|
-
STEP 1 of 2 \u2014
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
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
|
+
}),
|
|
4991
5231
|
{
|
|
4992
5232
|
verificationId: res.verificationId,
|
|
4993
5233
|
apexDomain: apex,
|
|
@@ -5020,6 +5260,7 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
|
|
|
5020
5260
|
server.registerTool(
|
|
5021
5261
|
"billing",
|
|
5022
5262
|
{
|
|
5263
|
+
title: "Billing snapshot",
|
|
5023
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).",
|
|
5024
5265
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5025
5266
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -5053,9 +5294,14 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
|
|
|
5053
5294
|
res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
|
|
5054
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
|
|
5055
5296
|
].filter((l) => l !== void 0);
|
|
5056
|
-
return textJson(
|
|
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")}
|
|
5057
5301
|
|
|
5058
|
-
Full status:`,
|
|
5302
|
+
Full status:`,
|
|
5303
|
+
res
|
|
5304
|
+
);
|
|
5059
5305
|
} catch (e) {
|
|
5060
5306
|
return toolError(e);
|
|
5061
5307
|
}
|
|
@@ -5064,6 +5310,7 @@ Full status:`, res);
|
|
|
5064
5310
|
server.registerTool(
|
|
5065
5311
|
"portal",
|
|
5066
5312
|
{
|
|
5313
|
+
title: "Billing portal (Stripe)",
|
|
5067
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.",
|
|
5068
5315
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5069
5316
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -5081,7 +5328,12 @@ Full status:`, res);
|
|
|
5081
5328
|
schemaVersion: 1,
|
|
5082
5329
|
outcome: "waiting_user",
|
|
5083
5330
|
resultCode: "site_billing_portal_ready",
|
|
5084
|
-
summary:
|
|
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
|
+
}),
|
|
5085
5337
|
data: { scope: args.scope, portalUrl: res2.portalUrl },
|
|
5086
5338
|
userAction: {
|
|
5087
5339
|
type: "open_url",
|
|
@@ -5097,7 +5349,14 @@ Full status:`, res);
|
|
|
5097
5349
|
schemaVersion: 1,
|
|
5098
5350
|
outcome: "waiting_user",
|
|
5099
5351
|
resultCode: "public_billing_recovery_portal_ready",
|
|
5100
|
-
summary:
|
|
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
|
+
}),
|
|
5101
5360
|
data: {
|
|
5102
5361
|
scope: args.scope,
|
|
5103
5362
|
portalUrl: res.portalUrl,
|
|
@@ -5120,6 +5379,7 @@ Full status:`, res);
|
|
|
5120
5379
|
server.registerTool(
|
|
5121
5380
|
"recover",
|
|
5122
5381
|
{
|
|
5382
|
+
title: "Recover site",
|
|
5123
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.",
|
|
5124
5384
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5125
5385
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
@@ -5133,7 +5393,7 @@ Full status:`, res);
|
|
|
5133
5393
|
preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
|
|
5134
5394
|
})
|
|
5135
5395
|
},
|
|
5136
|
-
async (args, call) => {
|
|
5396
|
+
withDecisionReentry("recover", async (args, call) => {
|
|
5137
5397
|
try {
|
|
5138
5398
|
const ctx = await withProjectDir(baseCtx, call);
|
|
5139
5399
|
if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
|
|
@@ -5341,7 +5601,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
5341
5601
|
...revokeArguments,
|
|
5342
5602
|
preserveExistingCredentials: true
|
|
5343
5603
|
};
|
|
5344
|
-
return
|
|
5604
|
+
return presentDecision(baseCtx.decisions, call, "recover", {
|
|
5345
5605
|
resultCode: "domain_recovery_ready",
|
|
5346
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.",
|
|
5347
5607
|
data: {
|
|
@@ -5476,11 +5736,12 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5476
5736
|
} catch (e) {
|
|
5477
5737
|
return toolError(e);
|
|
5478
5738
|
}
|
|
5479
|
-
}
|
|
5739
|
+
})
|
|
5480
5740
|
);
|
|
5481
5741
|
server.registerTool(
|
|
5482
5742
|
"support",
|
|
5483
5743
|
{
|
|
5744
|
+
title: "Support ticket",
|
|
5484
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.",
|
|
5485
5746
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5486
5747
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -5504,7 +5765,14 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5504
5765
|
});
|
|
5505
5766
|
return text(
|
|
5506
5767
|
"support_ticket_created",
|
|
5507
|
-
|
|
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
|
+
}),
|
|
5508
5776
|
{ ticketId: res.ticketId, status: res.status }
|
|
5509
5777
|
);
|
|
5510
5778
|
} catch (e) {
|
|
@@ -5515,6 +5783,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5515
5783
|
server.registerTool(
|
|
5516
5784
|
"report",
|
|
5517
5785
|
{
|
|
5786
|
+
title: "Bug report",
|
|
5518
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.",
|
|
5519
5788
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5520
5789
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -5536,7 +5805,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5536
5805
|
confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
|
|
5537
5806
|
})
|
|
5538
5807
|
},
|
|
5539
|
-
async (args, call) => {
|
|
5808
|
+
withDecisionReentry("report", async (args, call) => {
|
|
5540
5809
|
try {
|
|
5541
5810
|
requireReportAuthorization(baseCtx, args.helpAuthorization, args.toolName);
|
|
5542
5811
|
const ctx = await optionalProjectContext(baseCtx, call);
|
|
@@ -5566,7 +5835,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5566
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). ";
|
|
5567
5836
|
const confirmation = { confirmSubmit: true };
|
|
5568
5837
|
const confirmArguments = { ...args, ...confirmation };
|
|
5569
|
-
return
|
|
5838
|
+
return presentDecision(baseCtx.decisions, call, "report", {
|
|
5570
5839
|
resultCode: "bug_report_preview_ready",
|
|
5571
5840
|
outcome: "preview",
|
|
5572
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:
|
|
@@ -5608,7 +5877,7 @@ Summary: ${res.sanitizedSummary}`,
|
|
|
5608
5877
|
} catch (e) {
|
|
5609
5878
|
return toolError(e);
|
|
5610
5879
|
}
|
|
5611
|
-
}
|
|
5880
|
+
})
|
|
5612
5881
|
);
|
|
5613
5882
|
}
|
|
5614
5883
|
|
|
@@ -5618,6 +5887,7 @@ function registerBillingTools(server, baseCtx) {
|
|
|
5618
5887
|
server.registerTool(
|
|
5619
5888
|
"plans",
|
|
5620
5889
|
{
|
|
5890
|
+
title: "Hosting plan catalog",
|
|
5621
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.",
|
|
5622
5892
|
inputSchema: z3.object({}),
|
|
5623
5893
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
@@ -5630,7 +5900,21 @@ function registerBillingTools(server, baseCtx) {
|
|
|
5630
5900
|
schemaVersion: 1,
|
|
5631
5901
|
outcome: "completed",
|
|
5632
5902
|
resultCode: "billing_catalog_returned",
|
|
5633
|
-
summary:
|
|
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
|
+
}),
|
|
5634
5918
|
data: { catalog },
|
|
5635
5919
|
nextActions: [{ tool: "subscribe", allowed: true }]
|
|
5636
5920
|
});
|
|
@@ -5642,6 +5926,7 @@ function registerBillingTools(server, baseCtx) {
|
|
|
5642
5926
|
server.registerTool(
|
|
5643
5927
|
"change",
|
|
5644
5928
|
{
|
|
5929
|
+
title: "Change subscription (Stripe)",
|
|
5645
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.",
|
|
5646
5931
|
inputSchema: z3.object({
|
|
5647
5932
|
operationId: z3.string().min(1)
|
|
@@ -5662,7 +5947,25 @@ function registerBillingTools(server, baseCtx) {
|
|
|
5662
5947
|
outcome: "waiting_user",
|
|
5663
5948
|
resultCode: "stripe_subscription_management_required",
|
|
5664
5949
|
operationId: args.operationId,
|
|
5665
|
-
summary:
|
|
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
|
+
}),
|
|
5666
5969
|
data: { portalUrl: result.portalUrl, result },
|
|
5667
5970
|
userAction: {
|
|
5668
5971
|
type: "open_url",
|
|
@@ -5933,6 +6236,7 @@ function registerHelpTools(server, baseCtx) {
|
|
|
5933
6236
|
server.registerTool(
|
|
5934
6237
|
"init",
|
|
5935
6238
|
{
|
|
6239
|
+
title: "Initialize project",
|
|
5936
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.",
|
|
5937
6241
|
inputSchema: z4.object({}),
|
|
5938
6242
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
@@ -5951,7 +6255,16 @@ function registerHelpTools(server, baseCtx) {
|
|
|
5951
6255
|
schemaVersion: 1,
|
|
5952
6256
|
outcome: "completed",
|
|
5953
6257
|
resultCode: "project_initialized",
|
|
5954
|
-
summary:
|
|
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
|
+
}),
|
|
5955
6268
|
data: {
|
|
5956
6269
|
projectRoot: ctx.projectDir,
|
|
5957
6270
|
sakupaDirectory,
|
|
@@ -5973,6 +6286,7 @@ function registerHelpTools(server, baseCtx) {
|
|
|
5973
6286
|
server.registerTool(
|
|
5974
6287
|
"help",
|
|
5975
6288
|
{
|
|
6289
|
+
title: "Help and diagnosis",
|
|
5976
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.",
|
|
5977
6291
|
inputSchema: z4.object({
|
|
5978
6292
|
topic: z4.enum(HELP_TOPICS).optional().default("diagnose"),
|
|
@@ -6000,7 +6314,25 @@ function registerHelpTools(server, baseCtx) {
|
|
|
6000
6314
|
schemaVersion: 1,
|
|
6001
6315
|
outcome: "completed",
|
|
6002
6316
|
resultCode: "help_overview",
|
|
6003
|
-
summary:
|
|
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
|
+
}),
|
|
6004
6336
|
data: {
|
|
6005
6337
|
tools: catalog,
|
|
6006
6338
|
toolOrder: TOOL_TOPICS,
|
|
@@ -6035,13 +6367,20 @@ function registerHelpTools(server, baseCtx) {
|
|
|
6035
6367
|
schemaVersion: 1,
|
|
6036
6368
|
outcome: "completed",
|
|
6037
6369
|
resultCode: "help_tool_manual",
|
|
6038
|
-
summary:
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
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
|
+
}),
|
|
6045
6384
|
data: { tool: args.topic, ...manual, relatedTerminology },
|
|
6046
6385
|
nextActions: []
|
|
6047
6386
|
});
|
|
@@ -6089,7 +6428,23 @@ Terminology: ${terminologyText}` : ""),
|
|
|
6089
6428
|
}
|
|
6090
6429
|
] : credentialRotationState === "pending" ? [{ tool: "rotate", allowed: true, reasonCode: "resume_confirmed_rotation" }] : diagnosis.diagnosisCode === "workspace_not_initialized" ? [{ tool: "init", allowed: true, reasonCode: "initialize_active_root" }] : [];
|
|
6091
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." : "";
|
|
6092
|
-
const summary =
|
|
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
|
+
});
|
|
6093
6448
|
return structuredToolResult({
|
|
6094
6449
|
schemaVersion: 1,
|
|
6095
6450
|
outcome: diagnosis.diagnosisCode === "project_bound" ? "completed" : "blocked",
|
|
@@ -6124,6 +6479,7 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6124
6479
|
server.registerTool(
|
|
6125
6480
|
"rotate",
|
|
6126
6481
|
{
|
|
6482
|
+
title: "Rotate site credential",
|
|
6127
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.",
|
|
6128
6484
|
inputSchema: z5.object({
|
|
6129
6485
|
confirmed: z5.boolean().optional().describe(
|
|
@@ -6133,7 +6489,7 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6133
6489
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
6134
6490
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
|
|
6135
6491
|
},
|
|
6136
|
-
async (args, call) => {
|
|
6492
|
+
withDecisionReentry("rotate", async (args, call) => {
|
|
6137
6493
|
let releaseLock;
|
|
6138
6494
|
try {
|
|
6139
6495
|
const ctx = await withProjectDir(baseCtx, call);
|
|
@@ -6155,7 +6511,19 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6155
6511
|
schemaVersion: 1,
|
|
6156
6512
|
outcome: "completed",
|
|
6157
6513
|
resultCode: "credential_rotation_resumed",
|
|
6158
|
-
summary:
|
|
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
|
+
}),
|
|
6159
6527
|
data: {
|
|
6160
6528
|
siteId: site.siteId,
|
|
6161
6529
|
credentialCreatedAt: resumed.status.credentialCreatedAt,
|
|
@@ -6170,9 +6538,20 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6170
6538
|
const status = await ctx.client.getCredentialStatus(site.siteId, site.credential);
|
|
6171
6539
|
const confirmation = { confirmed: true };
|
|
6172
6540
|
if (args.confirmed !== true) {
|
|
6173
|
-
return
|
|
6541
|
+
return presentDecision(baseCtx.decisions, call, "rotate", {
|
|
6174
6542
|
resultCode: "credential_rotation_confirmation_required",
|
|
6175
|
-
summary:
|
|
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
|
+
}),
|
|
6176
6555
|
data: {
|
|
6177
6556
|
siteId: site.siteId,
|
|
6178
6557
|
credentialCreatedAt: status.credentialCreatedAt,
|
|
@@ -6223,7 +6602,21 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6223
6602
|
schemaVersion: 1,
|
|
6224
6603
|
outcome: "completed",
|
|
6225
6604
|
resultCode: "credential_rotated",
|
|
6226
|
-
summary:
|
|
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
|
+
}),
|
|
6227
6620
|
data: {
|
|
6228
6621
|
siteId: site.siteId,
|
|
6229
6622
|
credentialCreatedAt: completed.status.credentialCreatedAt,
|
|
@@ -6240,7 +6633,7 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6240
6633
|
} finally {
|
|
6241
6634
|
releaseLock?.();
|
|
6242
6635
|
}
|
|
6243
|
-
}
|
|
6636
|
+
})
|
|
6244
6637
|
);
|
|
6245
6638
|
}
|
|
6246
6639
|
|
|
@@ -6520,13 +6913,39 @@ Safety boundaries:
|
|
|
6520
6913
|
a bound custom domain, a lost credential is unrecoverable by design. portal then opens
|
|
6521
6914
|
Stripe's public no-code portal login, where the customer verifies the checkout email with a
|
|
6522
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
|
+
}
|
|
6523
6931
|
function createSakupaMcpServer(opts) {
|
|
6524
6932
|
const client = opts.client ?? new HttpApiClient(
|
|
6525
6933
|
new FetchTransport(opts.apiBaseUrl, { testAccessToken: opts.testAccessToken })
|
|
6526
6934
|
);
|
|
6935
|
+
const decisionCodec = createRequestStateCodec({
|
|
6936
|
+
key: randomBytes2(32),
|
|
6937
|
+
ttlSeconds: DECISION_STATE_TTL_SECONDS
|
|
6938
|
+
});
|
|
6527
6939
|
const server = new McpServer(
|
|
6528
6940
|
{ name: "sakupa", version: MCP_VERSION },
|
|
6529
|
-
{
|
|
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
|
+
}
|
|
6530
6949
|
);
|
|
6531
6950
|
const processCwd = resolve6(opts.projectDir ?? process.cwd());
|
|
6532
6951
|
const rootsProvider = opts.rootsProvider ?? ((call) => readClientRoots(server, call));
|
|
@@ -6541,7 +6960,11 @@ function createSakupaMcpServer(opts) {
|
|
|
6541
6960
|
rootsProvider,
|
|
6542
6961
|
MCP_ROOTS_TIMEOUT_MS,
|
|
6543
6962
|
opts.projectRoot
|
|
6544
|
-
)
|
|
6963
|
+
),
|
|
6964
|
+
decisions: {
|
|
6965
|
+
supportsFormElicitation: (call) => clientSupportsFormElicitation(server, call),
|
|
6966
|
+
codec: decisionCodec
|
|
6967
|
+
}
|
|
6545
6968
|
};
|
|
6546
6969
|
registerTools(server, ctx);
|
|
6547
6970
|
registerBillingTools(server, ctx);
|