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