@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/index.js
CHANGED
|
@@ -147,7 +147,7 @@ function isFreeSiteAllowanceNetworkReference(value) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
// ../core/dist/domain/version.js
|
|
150
|
-
var SAKUPA_MCP_VERSION = "1.
|
|
150
|
+
var SAKUPA_MCP_VERSION = "1.3.0";
|
|
151
151
|
|
|
152
152
|
// ../core/dist/domain/errors.js
|
|
153
153
|
var HTTP_STATUS = {
|
|
@@ -487,13 +487,15 @@ function previewHostPatternFor(apiBaseUrl) {
|
|
|
487
487
|
function loadMcpRuntimeConfig(env = process.env) {
|
|
488
488
|
const apiBaseUrl = (env["SAKUPA_API_URL"] ?? env["SAKUPA_API_BASE_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
489
489
|
const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
|
|
490
|
+
const projectRoot = env["SAKUPA_PROJECT_ROOT"]?.trim() ?? "";
|
|
491
|
+
const projectRootConfig = projectRoot.length > 0 ? { projectRoot } : {};
|
|
490
492
|
if (apiBaseUrl === TEST_API_BASE_URL) {
|
|
491
493
|
if (testAccessToken.length === 0) {
|
|
492
494
|
throw new Error(
|
|
493
495
|
"The Sakupa Test API requires SAKUPA_TEST_ACCESS_TOKEN. Anonymous Test access is disabled."
|
|
494
496
|
);
|
|
495
497
|
}
|
|
496
|
-
return { apiBaseUrl, testAccessToken };
|
|
498
|
+
return { apiBaseUrl, testAccessToken, ...projectRootConfig };
|
|
497
499
|
}
|
|
498
500
|
if (apiBaseUrl !== PRODUCTION_API_BASE_URL) {
|
|
499
501
|
throw new Error(
|
|
@@ -505,7 +507,7 @@ function loadMcpRuntimeConfig(env = process.env) {
|
|
|
505
507
|
`SAKUPA_TEST_ACCESS_TOKEN may only be used with ${TEST_API_BASE_URL}. Remove it before connecting to any other API.`
|
|
506
508
|
);
|
|
507
509
|
}
|
|
508
|
-
return { apiBaseUrl };
|
|
510
|
+
return { apiBaseUrl, ...projectRootConfig };
|
|
509
511
|
}
|
|
510
512
|
function environmentFor(apiBaseUrl) {
|
|
511
513
|
if (apiBaseUrl === TEST_API_BASE_URL) return "test";
|
|
@@ -1543,7 +1545,7 @@ import {
|
|
|
1543
1545
|
|
|
1544
1546
|
// src/project-binding.ts
|
|
1545
1547
|
import { fileURLToPath } from "node:url";
|
|
1546
|
-
import { resolve as resolve3 } from "node:path";
|
|
1548
|
+
import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path";
|
|
1547
1549
|
|
|
1548
1550
|
// src/project-root.ts
|
|
1549
1551
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
@@ -1783,10 +1785,11 @@ var ProjectBindingError = class extends Error {
|
|
|
1783
1785
|
}
|
|
1784
1786
|
};
|
|
1785
1787
|
var ProjectBindingResolver = class {
|
|
1786
|
-
constructor(processCwd, rootsProvider, rootsTimeoutMs = MCP_ROOTS_TIMEOUT_MS) {
|
|
1788
|
+
constructor(processCwd, rootsProvider, rootsTimeoutMs = MCP_ROOTS_TIMEOUT_MS, configuredRoot) {
|
|
1787
1789
|
this.processCwd = processCwd;
|
|
1788
1790
|
this.rootsProvider = rootsProvider;
|
|
1789
1791
|
this.rootsTimeoutMs = rootsTimeoutMs;
|
|
1792
|
+
this.configuredRoot = configuredRoot;
|
|
1790
1793
|
}
|
|
1791
1794
|
bound;
|
|
1792
1795
|
boundState;
|
|
@@ -1825,28 +1828,41 @@ var ProjectBindingResolver = class {
|
|
|
1825
1828
|
throw new ProjectBindingError(inspection.diagnostics);
|
|
1826
1829
|
}
|
|
1827
1830
|
const initialized = initializeProject(inspection.initializableRoot);
|
|
1828
|
-
this.bound = {
|
|
1831
|
+
this.bound = {
|
|
1832
|
+
...initialized,
|
|
1833
|
+
bindingSource: inspection.initializableSource ?? "mcp_root"
|
|
1834
|
+
};
|
|
1829
1835
|
this.boundState = boundDiagnostics(
|
|
1830
1836
|
this.processCwd,
|
|
1831
1837
|
this.bound,
|
|
1832
|
-
{ supported:
|
|
1833
|
-
inspection.diagnostics.rootCandidates
|
|
1838
|
+
{ supported: inspection.diagnostics.mcpRootsSupported, roots: [] },
|
|
1839
|
+
inspection.diagnostics.rootCandidates,
|
|
1840
|
+
inspection.diagnostics.configuredProjectRoot
|
|
1834
1841
|
);
|
|
1835
1842
|
return this.bound;
|
|
1836
1843
|
}
|
|
1837
1844
|
async inspect(forInitialization = false, call) {
|
|
1838
1845
|
const snapshot = await safeRootsSnapshot(this.rootsProvider, this.rootsTimeoutMs, call);
|
|
1839
1846
|
const rootCandidates = snapshot.roots.map(inspectRoot);
|
|
1847
|
+
const configured = this.configuredRoot === void 0 ? void 0 : inspectConfiguredRoot(this.configuredRoot);
|
|
1848
|
+
const diagnose = (code, guidance) => diagnostic(code, snapshot, this.processCwd, rootCandidates, guidance, configured);
|
|
1849
|
+
const bound = (selected) => ({
|
|
1850
|
+
selected,
|
|
1851
|
+
diagnostics: boundDiagnostics(
|
|
1852
|
+
this.processCwd,
|
|
1853
|
+
selected,
|
|
1854
|
+
snapshot,
|
|
1855
|
+
rootCandidates,
|
|
1856
|
+
configured
|
|
1857
|
+
)
|
|
1858
|
+
});
|
|
1840
1859
|
const initializedRoots = rootCandidates.filter(
|
|
1841
1860
|
(candidate) => candidate.initialized && candidate.path !== void 0
|
|
1842
1861
|
);
|
|
1843
1862
|
if (snapshot.supported && snapshot.error) {
|
|
1844
1863
|
return {
|
|
1845
|
-
diagnostics:
|
|
1864
|
+
diagnostics: diagnose(
|
|
1846
1865
|
"roots_request_failed",
|
|
1847
|
-
snapshot,
|
|
1848
|
-
this.processCwd,
|
|
1849
|
-
rootCandidates,
|
|
1850
1866
|
"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."
|
|
1851
1867
|
)
|
|
1852
1868
|
};
|
|
@@ -1855,19 +1871,12 @@ var ProjectBindingResolver = class {
|
|
|
1855
1871
|
const initializedRoot = initializedRoots[0];
|
|
1856
1872
|
if (!initializedRoot) throw new Error("initialized Root disappeared during resolution");
|
|
1857
1873
|
const project = resolveLockedProjectRoot(initializedRoot.path);
|
|
1858
|
-
|
|
1859
|
-
return {
|
|
1860
|
-
selected,
|
|
1861
|
-
diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
|
|
1862
|
-
};
|
|
1874
|
+
return bound({ ...project, bindingSource: "mcp_root" });
|
|
1863
1875
|
}
|
|
1864
1876
|
if (initializedRoots.length > 1) {
|
|
1865
1877
|
return {
|
|
1866
|
-
diagnostics:
|
|
1878
|
+
diagnostics: diagnose(
|
|
1867
1879
|
"multiple_initialized_roots",
|
|
1868
|
-
snapshot,
|
|
1869
|
-
this.processCwd,
|
|
1870
|
-
rootCandidates,
|
|
1871
1880
|
"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."
|
|
1872
1881
|
)
|
|
1873
1882
|
};
|
|
@@ -1881,22 +1890,17 @@ var ProjectBindingResolver = class {
|
|
|
1881
1890
|
if (!validRoot) throw new Error("workspace Root disappeared during initialization");
|
|
1882
1891
|
return {
|
|
1883
1892
|
initializableRoot: validRoot.path,
|
|
1884
|
-
|
|
1893
|
+
initializableSource: "mcp_root",
|
|
1894
|
+
diagnostics: diagnose(
|
|
1885
1895
|
"workspace_not_initialized",
|
|
1886
|
-
snapshot,
|
|
1887
|
-
this.processCwd,
|
|
1888
|
-
rootCandidates,
|
|
1889
1896
|
`The active MCP workspace ${validRoot.path} is ready to initialize.`
|
|
1890
1897
|
)
|
|
1891
1898
|
};
|
|
1892
1899
|
}
|
|
1893
1900
|
if (validRoots.length > 1) {
|
|
1894
1901
|
return {
|
|
1895
|
-
diagnostics:
|
|
1902
|
+
diagnostics: diagnose(
|
|
1896
1903
|
"multiple_uninitialized_roots",
|
|
1897
|
-
snapshot,
|
|
1898
|
-
this.processCwd,
|
|
1899
|
-
rootCandidates,
|
|
1900
1904
|
"The IDE exposes multiple uninitialized workspace Roots. Open only the intended project before calling init; Sakupa will not choose a directory for the user."
|
|
1901
1905
|
)
|
|
1902
1906
|
};
|
|
@@ -1906,54 +1910,59 @@ var ProjectBindingResolver = class {
|
|
|
1906
1910
|
const validRoot = validRoots[0];
|
|
1907
1911
|
if (!validRoot) throw new Error("workspace Root disappeared during diagnosis");
|
|
1908
1912
|
return {
|
|
1909
|
-
diagnostics:
|
|
1913
|
+
diagnostics: diagnose(
|
|
1910
1914
|
"workspace_not_initialized",
|
|
1911
|
-
snapshot,
|
|
1912
|
-
this.processCwd,
|
|
1913
|
-
rootCandidates,
|
|
1914
1915
|
`The IDE workspace ${validRoot.path} is not initialized. Call init with no path arguments; it will create .sakupa directly in that workspace Root.`
|
|
1915
1916
|
)
|
|
1916
1917
|
};
|
|
1917
1918
|
}
|
|
1918
1919
|
if (snapshot.supported && validRoots.length > 1) {
|
|
1919
1920
|
return {
|
|
1920
|
-
diagnostics:
|
|
1921
|
+
diagnostics: diagnose(
|
|
1921
1922
|
"multiple_uninitialized_roots",
|
|
1922
|
-
snapshot,
|
|
1923
|
-
this.processCwd,
|
|
1924
|
-
rootCandidates,
|
|
1925
1923
|
"The IDE exposes multiple uninitialized workspace Roots. Open only the intended project, then call init. Sakupa will not guess a project directory."
|
|
1926
1924
|
)
|
|
1927
1925
|
};
|
|
1928
1926
|
}
|
|
1927
|
+
if (configured) {
|
|
1928
|
+
if (configured.problem !== void 0 || configured.path === void 0) {
|
|
1929
|
+
return {
|
|
1930
|
+
diagnostics: diagnose(
|
|
1931
|
+
"invalid_configured_root",
|
|
1932
|
+
`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.`
|
|
1933
|
+
)
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
if (configured.initialized) {
|
|
1937
|
+
const project = resolveLockedProjectRoot(configured.path);
|
|
1938
|
+
return bound({ ...project, bindingSource: "configured_root" });
|
|
1939
|
+
}
|
|
1940
|
+
return {
|
|
1941
|
+
...forInitialization ? { initializableRoot: configured.path, initializableSource: "configured_root" } : {},
|
|
1942
|
+
diagnostics: diagnose(
|
|
1943
|
+
"workspace_not_initialized",
|
|
1944
|
+
`The configured project root ${configured.path} (SAKUPA_PROJECT_ROOT) is not initialized. Call init with no path arguments; it will create .sakupa directly there.`
|
|
1945
|
+
)
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1929
1948
|
if (snapshot.supported) {
|
|
1930
1949
|
return {
|
|
1931
|
-
diagnostics:
|
|
1950
|
+
diagnostics: diagnose(
|
|
1932
1951
|
"workspace_not_initialized",
|
|
1933
|
-
snapshot,
|
|
1934
|
-
this.processCwd,
|
|
1935
|
-
rootCandidates,
|
|
1936
1952
|
"The IDE did not expose one usable file workspace Root. Open exactly one local project workspace, then retry help before calling init or deploy."
|
|
1937
1953
|
)
|
|
1938
1954
|
};
|
|
1939
1955
|
}
|
|
1940
1956
|
try {
|
|
1941
1957
|
const cwdProject = resolveLockedProjectRoot(this.processCwd);
|
|
1942
|
-
|
|
1943
|
-
return {
|
|
1944
|
-
selected,
|
|
1945
|
-
diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
|
|
1946
|
-
};
|
|
1958
|
+
return bound({ ...cwdProject, bindingSource: "process_cwd" });
|
|
1947
1959
|
} catch {
|
|
1948
1960
|
}
|
|
1949
1961
|
const cwdProblem = inspectDirectory(this.processCwd);
|
|
1950
1962
|
return {
|
|
1951
|
-
diagnostics:
|
|
1963
|
+
diagnostics: diagnose(
|
|
1952
1964
|
cwdProblem.problem ? "invalid_process_cwd" : "process_cwd_is_not_workspace",
|
|
1953
|
-
|
|
1954
|
-
this.processCwd,
|
|
1955
|
-
rootCandidates,
|
|
1956
|
-
"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."
|
|
1965
|
+
"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."
|
|
1957
1966
|
)
|
|
1958
1967
|
};
|
|
1959
1968
|
}
|
|
@@ -1966,11 +1975,7 @@ function fileRootUriToPath(uri, windows = process.platform === "win32") {
|
|
|
1966
1975
|
async function safeRootsSnapshot(provider, timeoutMs = MCP_ROOTS_TIMEOUT_MS, call) {
|
|
1967
1976
|
if (!provider) return { supported: false, roots: [] };
|
|
1968
1977
|
try {
|
|
1969
|
-
return await withOperationTimeout(
|
|
1970
|
-
"MCP Roots request",
|
|
1971
|
-
timeoutMs,
|
|
1972
|
-
() => provider(call)
|
|
1973
|
-
);
|
|
1978
|
+
return await withOperationTimeout("MCP Roots request", timeoutMs, () => provider(call));
|
|
1974
1979
|
} catch (error) {
|
|
1975
1980
|
if (error instanceof McpRootsPending) throw error;
|
|
1976
1981
|
return {
|
|
@@ -2000,6 +2005,31 @@ function inspectRoot(root) {
|
|
|
2000
2005
|
};
|
|
2001
2006
|
}
|
|
2002
2007
|
}
|
|
2008
|
+
function inspectConfiguredRoot(configured) {
|
|
2009
|
+
if (!isAbsolute2(configured)) {
|
|
2010
|
+
return {
|
|
2011
|
+
configured,
|
|
2012
|
+
initialized: false,
|
|
2013
|
+
problem: "the value must be an absolute path"
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
try {
|
|
2017
|
+
const path = canonicalProjectDirectory(configured);
|
|
2018
|
+
const marker = loadProjectMarker(path);
|
|
2019
|
+
return {
|
|
2020
|
+
configured,
|
|
2021
|
+
path,
|
|
2022
|
+
initialized: marker.kind === "ok",
|
|
2023
|
+
...marker.kind === "corrupted" ? { problem: marker.problem } : {}
|
|
2024
|
+
};
|
|
2025
|
+
} catch (error) {
|
|
2026
|
+
return {
|
|
2027
|
+
configured,
|
|
2028
|
+
initialized: false,
|
|
2029
|
+
problem: error instanceof Error ? error.message : String(error)
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2003
2033
|
function inspectDirectory(path) {
|
|
2004
2034
|
try {
|
|
2005
2035
|
return { path: canonicalProjectDirectory(resolve3(path)) };
|
|
@@ -2007,22 +2037,24 @@ function inspectDirectory(path) {
|
|
|
2007
2037
|
return { problem: error instanceof Error ? error.message : String(error) };
|
|
2008
2038
|
}
|
|
2009
2039
|
}
|
|
2010
|
-
function diagnostic(diagnosisCode, snapshot, processCwd, rootCandidates, guidance) {
|
|
2040
|
+
function diagnostic(diagnosisCode, snapshot, processCwd, rootCandidates, guidance, configured) {
|
|
2011
2041
|
return {
|
|
2012
2042
|
diagnosisCode,
|
|
2013
2043
|
mcpRootsSupported: snapshot.supported,
|
|
2014
2044
|
processCwd,
|
|
2015
2045
|
rootCandidates,
|
|
2046
|
+
...configured ? { configuredProjectRoot: configured } : {},
|
|
2016
2047
|
guidance,
|
|
2017
2048
|
reportRecommended: false
|
|
2018
2049
|
};
|
|
2019
2050
|
}
|
|
2020
|
-
function boundDiagnostics(processCwd, selected, snapshot = { supported: false, roots: [] }, rootCandidates = []) {
|
|
2051
|
+
function boundDiagnostics(processCwd, selected, snapshot = { supported: false, roots: [] }, rootCandidates = [], configured) {
|
|
2021
2052
|
return {
|
|
2022
2053
|
diagnosisCode: "project_bound",
|
|
2023
2054
|
mcpRootsSupported: snapshot.supported,
|
|
2024
2055
|
processCwd,
|
|
2025
2056
|
rootCandidates,
|
|
2057
|
+
...configured ? { configuredProjectRoot: configured } : {},
|
|
2026
2058
|
selectedProjectDir: selected.projectDir,
|
|
2027
2059
|
bindingSource: selected.bindingSource,
|
|
2028
2060
|
guidance: `Sakupa is locked to ${selected.projectDir} from ${selected.bindingSource}.`,
|
|
@@ -2188,10 +2220,51 @@ function structuredToolResult(envelope) {
|
|
|
2188
2220
|
return {
|
|
2189
2221
|
content: [{ type: "text", text: `${envelope.summary}
|
|
2190
2222
|
|
|
2223
|
+
---
|
|
2191
2224
|
${presentationFallback}` }],
|
|
2192
2225
|
structuredContent: structuredEnvelope
|
|
2193
2226
|
};
|
|
2194
2227
|
}
|
|
2228
|
+
var SUMMARY_HEADINGS = {
|
|
2229
|
+
steps: "Do this yourself",
|
|
2230
|
+
notes: "Notes",
|
|
2231
|
+
next: "Next"
|
|
2232
|
+
};
|
|
2233
|
+
function tableCell(value) {
|
|
2234
|
+
return String(value).replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
2235
|
+
}
|
|
2236
|
+
function summaryMarkdown(sections) {
|
|
2237
|
+
const blocks = [`## ${sections.title.trim()}`];
|
|
2238
|
+
if (sections.lead?.trim()) blocks.push(sections.lead.trim());
|
|
2239
|
+
const facts = (sections.facts ?? []).filter(
|
|
2240
|
+
(row) => row[1] !== void 0 && row[1] !== ""
|
|
2241
|
+
);
|
|
2242
|
+
if (facts.length > 0) {
|
|
2243
|
+
blocks.push(
|
|
2244
|
+
[
|
|
2245
|
+
"| Item | Value |",
|
|
2246
|
+
"|---|---|",
|
|
2247
|
+
...facts.map(([k, v]) => `| ${tableCell(k)} | ${tableCell(v)} |`)
|
|
2248
|
+
].join("\n")
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
if (sections.steps?.length) {
|
|
2252
|
+
blocks.push(
|
|
2253
|
+
`### ${SUMMARY_HEADINGS.steps}
|
|
2254
|
+
${sections.steps.map((step, i) => `${i + 1}. ${step}`).join("\n")}`
|
|
2255
|
+
);
|
|
2256
|
+
}
|
|
2257
|
+
if (sections.notes?.length) {
|
|
2258
|
+
blocks.push(`### ${SUMMARY_HEADINGS.notes}
|
|
2259
|
+
${sections.notes.map((n) => `- ${n}`).join("\n")}`);
|
|
2260
|
+
}
|
|
2261
|
+
if (sections.next?.length) {
|
|
2262
|
+
blocks.push(`### ${SUMMARY_HEADINGS.next}
|
|
2263
|
+
${sections.next.map((n) => `- ${n}`).join("\n")}`);
|
|
2264
|
+
}
|
|
2265
|
+
if (sections.raw?.trim()) blocks.push(sections.raw.trim());
|
|
2266
|
+
return blocks.join("\n\n");
|
|
2267
|
+
}
|
|
2195
2268
|
function timestampForAgent(exactTimestamp) {
|
|
2196
2269
|
return timestampForAgentInZone(exactTimestamp, clientRuntimeTimeZone());
|
|
2197
2270
|
}
|
|
@@ -2241,7 +2314,12 @@ function resolverFor(ctx) {
|
|
|
2241
2314
|
if (ctx.projectBinding) return ctx.projectBinding;
|
|
2242
2315
|
let resolver = fallbackResolvers.get(ctx);
|
|
2243
2316
|
if (!resolver) {
|
|
2244
|
-
resolver = new ProjectBindingResolver(
|
|
2317
|
+
resolver = new ProjectBindingResolver(
|
|
2318
|
+
ctx.projectDir,
|
|
2319
|
+
ctx.rootsProvider,
|
|
2320
|
+
MCP_ROOTS_TIMEOUT_MS,
|
|
2321
|
+
ctx.configuredProjectRoot
|
|
2322
|
+
);
|
|
2245
2323
|
fallbackResolvers.set(ctx, resolver);
|
|
2246
2324
|
}
|
|
2247
2325
|
return resolver;
|
|
@@ -2380,8 +2458,14 @@ function toolError(e) {
|
|
|
2380
2458
|
const serverGuidance = isSakupaError(e) && errorCode !== "internal" && errorCode !== "unauthorized" && errorCode !== "upgrade_required" && e.message.trim().length > 0 ? e.message : void 0;
|
|
2381
2459
|
const safeSummary = timeoutSummary ?? (e instanceof LocalGuidanceError ? e.message : errorCode === "upgrade_required" ? `This Sakupa MCP client is v${MCP_VERSION}, older than the server's minimum supported version${minimumVersion !== void 0 ? ` (v${minimumVersion})` : ""}, so the server refused the call. To fix it: ask the user to fully restart their MCP client session \u2014 "npx -y @sakupa/mcp@latest" setups fetch the current version on restart (run "npx clear-npx-cache" first if the old version persists); global installs need "npm install -g @sakupa/mcp@latest". After the restart, retry this exact tool call.` : errorCode === "unauthorized" ? UNAUTHORIZED_SUMMARY : serverGuidance ?? (retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : opaqueUnclassified ? "This failed with an error Sakupa could not classify, and retrying the same call will not help. Run help with the failed tool and error code first; only use report if help explicitly recommends it." : "The operation failed; no server-internal details are exposed."));
|
|
2382
2460
|
const customerMeaning = timedOut ? timeoutRetrySafe ? "Sakupa did not receive this read result before the deadline; no automatic retry occurred." : "Sakupa did not receive a final result before the deadline, so the AI must check current state before attempting another write." : errorCode === "unauthorized" ? "The cloud site is still intact, but this project no longer has a valid management credential for it." : errorCode === "upgrade_required" ? "The installed Sakupa MCP version is too old for the current API and must be refreshed before retrying." : errorCode === "payment_required" ? "This action needs an active subscription or a payment issue must be resolved first." : errorCode === "rate_limited" ? "Sakupa temporarily refused this operation because a usage or frequency limit was reached." : errorCode === "not_found" ? "The requested Sakupa site, project binding, or operation could not be found." : errorCode === "forbidden" ? "Sakupa refused this operation because the current authority or site state does not allow it." : errorCode === "conflict" || errorCode === "state_conflict" || errorCode === "confirmation_required" ? "Sakupa safely stopped because the site, billing state, or required confirmation no longer matches." : errorCode === "invalid_request" || errorCode === "validation_failed" ? "Sakupa could not complete the operation because required input or current state was invalid." : retryable ? "A temporary Sakupa dependency problem prevented completion." : "Sakupa did not complete the operation; use the retained diagnostics to determine the safe next step.";
|
|
2383
|
-
const userFacingSummary =
|
|
2384
|
-
|
|
2461
|
+
const userFacingSummary = summaryMarkdown({
|
|
2462
|
+
title: `Sakupa could not complete this operation (${errorCode})`,
|
|
2463
|
+
lead: `Customer meaning: ${customerMeaning}`,
|
|
2464
|
+
notes: [`Technical context for the AI: ${safeSummary}`],
|
|
2465
|
+
next: [
|
|
2466
|
+
'`help` with topic "diagnose", the failed tool name and this error code \u2014 before any retry, support request or report'
|
|
2467
|
+
]
|
|
2468
|
+
});
|
|
2385
2469
|
const result = structuredToolResult({
|
|
2386
2470
|
schemaVersion: 1,
|
|
2387
2471
|
outcome: "failed",
|
|
@@ -2418,7 +2502,7 @@ import { z as z2 } from "zod";
|
|
|
2418
2502
|
// src/recovery-archive.ts
|
|
2419
2503
|
import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
|
|
2420
2504
|
import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2421
|
-
import { dirname as dirname2, isAbsolute as
|
|
2505
|
+
import { dirname as dirname2, isAbsolute as isAbsolute3, join as join4, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
|
|
2422
2506
|
|
|
2423
2507
|
// ../../node_modules/fflate/esm/index.mjs
|
|
2424
2508
|
import { createRequire } from "module";
|
|
@@ -2905,13 +2989,13 @@ function unzipSync(data, opts) {
|
|
|
2905
2989
|
|
|
2906
2990
|
// src/recovery-archive.ts
|
|
2907
2991
|
function safeOutputPath(projectDir, outputDir) {
|
|
2908
|
-
if (outputDir.length === 0 ||
|
|
2992
|
+
if (outputDir.length === 0 || isAbsolute3(outputDir)) {
|
|
2909
2993
|
throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
|
|
2910
2994
|
}
|
|
2911
2995
|
const root = realpathSync2(resolve4(projectDir));
|
|
2912
2996
|
const target = resolve4(root, outputDir);
|
|
2913
2997
|
const rel = relative2(root, target);
|
|
2914
|
-
if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) ||
|
|
2998
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute3(rel)) {
|
|
2915
2999
|
throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
|
|
2916
3000
|
}
|
|
2917
3001
|
if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep3}`)) {
|
|
@@ -2926,7 +3010,7 @@ function safeOutputPath(projectDir, outputDir) {
|
|
|
2926
3010
|
const physicalAncestor = realpathSync2(existingAncestor);
|
|
2927
3011
|
const physicalTarget = resolve4(physicalAncestor, relative2(existingAncestor, target));
|
|
2928
3012
|
const physicalRel = relative2(root, physicalTarget);
|
|
2929
|
-
if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) ||
|
|
3013
|
+
if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) || isAbsolute3(physicalRel)) {
|
|
2930
3014
|
throw new SakupaError(
|
|
2931
3015
|
"invalid_request",
|
|
2932
3016
|
"Recovery outputDir resolves through a symlink outside projectDir"
|
|
@@ -3292,7 +3376,7 @@ import {
|
|
|
3292
3376
|
writeFileSync as writeFileSync5
|
|
3293
3377
|
} from "node:fs";
|
|
3294
3378
|
import { createHash } from "node:crypto";
|
|
3295
|
-
import { dirname as dirname5, isAbsolute as
|
|
3379
|
+
import { dirname as dirname5, isAbsolute as isAbsolute4, join as join7 } from "node:path";
|
|
3296
3380
|
var HANDOFF_LOCK_TTL_MS = 15 * 60 * 1e3;
|
|
3297
3381
|
function normalizeSiteUrl(raw) {
|
|
3298
3382
|
const url = new URL(raw);
|
|
@@ -3319,7 +3403,7 @@ function resolveReusableSite(rawUrl, currentProjectDir, nowMs, apiBaseUrl) {
|
|
|
3319
3403
|
throw new Error(`More than one local free-site record matches ${siteUrl}.`);
|
|
3320
3404
|
const record = matches2[0];
|
|
3321
3405
|
if (!record) throw new Error("The selected existing free site disappeared during resolution.");
|
|
3322
|
-
if (!
|
|
3406
|
+
if (!isAbsolute4(record.projectDir)) {
|
|
3323
3407
|
throw new Error("The selected free-site project path is not absolute; refusing cwd lookup.");
|
|
3324
3408
|
}
|
|
3325
3409
|
const sourceProjectDir = canonicalProjectDirectory(record.projectDir);
|
|
@@ -3713,6 +3797,7 @@ async function resumeCredentialRotation(client, projectDir, site, apiBaseUrl) {
|
|
|
3713
3797
|
}
|
|
3714
3798
|
|
|
3715
3799
|
// src/tools/decision.ts
|
|
3800
|
+
import { acceptedContent, inputRequired as inputRequired2 } from "@modelcontextprotocol/server";
|
|
3716
3801
|
var DECISION_PRESENTATION_POLICY = {
|
|
3717
3802
|
translateFields: [
|
|
3718
3803
|
"decision.prompt",
|
|
@@ -3805,11 +3890,14 @@ function buildDecisionContract(prompt, options) {
|
|
|
3805
3890
|
function formatDecisionFallback(decision) {
|
|
3806
3891
|
const options = decision.options.map((option, index) => {
|
|
3807
3892
|
const consequences = option.consequences.length === 0 ? "" : `
|
|
3808
|
-
Consequences: ${option.consequences.join(" ")}`;
|
|
3893
|
+
- Consequences: ${option.consequences.join(" ")}`;
|
|
3809
3894
|
let exactAction;
|
|
3810
3895
|
switch (option.nextAction.type) {
|
|
3811
3896
|
case "call_tool":
|
|
3812
|
-
exactAction = `If the user selects this option, call
|
|
3897
|
+
exactAction = `If the user selects this option, call \`${option.nextAction.tool}\` with these exact arguments:
|
|
3898
|
+
\`\`\`json
|
|
3899
|
+
${JSON.stringify(option.nextAction.arguments)}
|
|
3900
|
+
\`\`\``;
|
|
3813
3901
|
break;
|
|
3814
3902
|
case "open_url":
|
|
3815
3903
|
exactAction = `If the user selects this option, present this exact URL: ${option.nextAction.url}.`;
|
|
@@ -3818,11 +3906,13 @@ function formatDecisionFallback(decision) {
|
|
|
3818
3906
|
exactAction = "If the user selects this option, call no tool and make no change.";
|
|
3819
3907
|
break;
|
|
3820
3908
|
}
|
|
3821
|
-
return `${index + 1}. [${option.id}]
|
|
3909
|
+
return `${index + 1}. [${option.id}] **${option.label}**
|
|
3822
3910
|
${option.description}${consequences}
|
|
3823
3911
|
${exactAction}`;
|
|
3824
3912
|
});
|
|
3825
|
-
return
|
|
3913
|
+
return `### USER DECISION REQUIRED
|
|
3914
|
+
${decision.prompt}
|
|
3915
|
+
|
|
3826
3916
|
No option is selected by default. Present every option to the user, do not choose on their behalf, and never reconstruct or guess tool arguments.
|
|
3827
3917
|
|
|
3828
3918
|
` + options.join("\n\n");
|
|
@@ -3902,6 +3992,76 @@ function noActionDecisionOption(input) {
|
|
|
3902
3992
|
nextAction: { type: "none" }
|
|
3903
3993
|
};
|
|
3904
3994
|
}
|
|
3995
|
+
var DECISION_INPUT_KEY = "decision";
|
|
3996
|
+
var declinedCalls = /* @__PURE__ */ new WeakSet();
|
|
3997
|
+
function formatElicitationMessage(decision) {
|
|
3998
|
+
const lines = decision.options.map((option, index) => {
|
|
3999
|
+
const consequences = option.consequences.length === 0 ? "" : ` Consequences: ${option.consequences.join(" ")}`;
|
|
4000
|
+
return `${index + 1}. ${option.label} \u2014 ${option.description}${consequences}`;
|
|
4001
|
+
});
|
|
4002
|
+
return `${decision.prompt}
|
|
4003
|
+
|
|
4004
|
+
${lines.join("\n")}`;
|
|
4005
|
+
}
|
|
4006
|
+
function presentDecision(runtime, call, tool, input) {
|
|
4007
|
+
const decision = buildDecisionContract(input.prompt, input.options);
|
|
4008
|
+
if (!runtime || !call || declinedCalls.has(call) || !runtime.supportsFormElicitation(call)) {
|
|
4009
|
+
return Promise.resolve(decisionToolResult(input));
|
|
4010
|
+
}
|
|
4011
|
+
const args = {};
|
|
4012
|
+
for (const option of decision.options) {
|
|
4013
|
+
if (option.nextAction.type === "call_tool" && option.nextAction.tool === tool) {
|
|
4014
|
+
args[option.id] = option.nextAction.arguments;
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
if (Object.keys(args).length === 0) return Promise.resolve(decisionToolResult(input));
|
|
4018
|
+
return runtime.codec.mint({ v: 1, tool, decisionId: input.resultCode, arguments: args }, call).then(
|
|
4019
|
+
(requestState) => inputRequired2({
|
|
4020
|
+
requestState,
|
|
4021
|
+
inputRequests: {
|
|
4022
|
+
[DECISION_INPUT_KEY]: inputRequired2.elicit({
|
|
4023
|
+
message: formatElicitationMessage(decision),
|
|
4024
|
+
requestedSchema: {
|
|
4025
|
+
type: "object",
|
|
4026
|
+
properties: {
|
|
4027
|
+
choice: {
|
|
4028
|
+
type: "string",
|
|
4029
|
+
title: "Your choice",
|
|
4030
|
+
description: decision.prompt,
|
|
4031
|
+
oneOf: decision.options.map((option) => ({
|
|
4032
|
+
const: option.id,
|
|
4033
|
+
title: option.label
|
|
4034
|
+
}))
|
|
4035
|
+
}
|
|
4036
|
+
},
|
|
4037
|
+
required: ["choice"]
|
|
4038
|
+
}
|
|
4039
|
+
})
|
|
4040
|
+
}
|
|
4041
|
+
})
|
|
4042
|
+
);
|
|
4043
|
+
}
|
|
4044
|
+
function restoreDecisionChoice(call, tool) {
|
|
4045
|
+
const responses = call?.mcpReq.inputResponses;
|
|
4046
|
+
if (!call || !responses || !(DECISION_INPUT_KEY in responses)) return null;
|
|
4047
|
+
const state = call.mcpReq.requestState();
|
|
4048
|
+
if (!state || typeof state !== "object" || state.v !== 1 || state.tool !== tool) return null;
|
|
4049
|
+
const content = acceptedContent(responses, DECISION_INPUT_KEY);
|
|
4050
|
+
const choice = typeof content?.choice === "string" ? content.choice : void 0;
|
|
4051
|
+
const args = choice !== void 0 ? state.arguments[choice] : void 0;
|
|
4052
|
+
if (!args) {
|
|
4053
|
+
declinedCalls.add(call);
|
|
4054
|
+
return { kind: "declined" };
|
|
4055
|
+
}
|
|
4056
|
+
return { kind: "chosen", optionId: choice, arguments: args };
|
|
4057
|
+
}
|
|
4058
|
+
function withDecisionReentry(tool, handler) {
|
|
4059
|
+
return (args, call) => {
|
|
4060
|
+
const restored = restoreDecisionChoice(call, tool);
|
|
4061
|
+
if (restored?.kind === "chosen") return handler({ ...args, ...restored.arguments }, call);
|
|
4062
|
+
return handler(args, call);
|
|
4063
|
+
};
|
|
4064
|
+
}
|
|
3905
4065
|
|
|
3906
4066
|
// src/tools/definitions.ts
|
|
3907
4067
|
function text(resultCode, t, data = {}, outcome = "completed", nextActions = []) {
|
|
@@ -3914,9 +4074,14 @@ function text(resultCode, t, data = {}, outcome = "completed", nextActions = [])
|
|
|
3914
4074
|
nextActions
|
|
3915
4075
|
});
|
|
3916
4076
|
}
|
|
3917
|
-
function textJson(resultCode,
|
|
3918
|
-
const summary =
|
|
3919
|
-
|
|
4077
|
+
function textJson(resultCode, title, lead, obj, outcome = "completed") {
|
|
4078
|
+
const summary = summaryMarkdown({
|
|
4079
|
+
title,
|
|
4080
|
+
lead,
|
|
4081
|
+
raw: `\`\`\`json
|
|
4082
|
+
${JSON.stringify(obj, null, 2)}
|
|
4083
|
+
\`\`\``
|
|
4084
|
+
});
|
|
3920
4085
|
return structuredToolResult({
|
|
3921
4086
|
schemaVersion: 1,
|
|
3922
4087
|
outcome,
|
|
@@ -3959,7 +4124,8 @@ function analysisSummary(analysis) {
|
|
|
3959
4124
|
function notDeployableResult(analysis) {
|
|
3960
4125
|
return textJson(
|
|
3961
4126
|
"site_analysis_not_deployable",
|
|
3962
|
-
|
|
4127
|
+
"This project is NOT deployable as-is",
|
|
4128
|
+
`No files were uploaded and no API call was made.
|
|
3963
4129
|
Next action: ${analysis.suggestedNextAction}
|
|
3964
4130
|
Analysis:`,
|
|
3965
4131
|
analysisSummary(analysis),
|
|
@@ -4050,14 +4216,22 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
|
|
|
4050
4216
|
};
|
|
4051
4217
|
}
|
|
4052
4218
|
}
|
|
4053
|
-
function freeSiteCreationBarrier(sites, deployArguments, allowanceNetworkReference) {
|
|
4054
|
-
const
|
|
4219
|
+
function freeSiteCreationBarrier(decisions, call, sites, deployArguments, allowanceNetworkReference) {
|
|
4220
|
+
const summary = summaryMarkdown({
|
|
4221
|
+
title: "Free-site allowance is full \u2014 choose a site to hand off",
|
|
4222
|
+
lead: `Sakupa cloud confirmed that this network already has ${FREE_ACTIVE_SITES_PER_IP} active free sites, so no new site was created. Authenticated device discovery found ${sites.length} free site(s) this device can hand off:
|
|
4055
4223
|
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4224
|
+
` + sites.map((site) => `- ${site.url} (expires ${timestampForAgent(site.expiresAt)})`).join("\n"),
|
|
4225
|
+
notes: [
|
|
4226
|
+
"The free-site allowance is full. Ask the user which existing free URL may have its content REPLACED by the current project.",
|
|
4227
|
+
"Selecting one authorizes a site handoff: deploy keeps that URL, overwrites its online content with the current files, issues a fresh project credential, and revokes every previous credential. The cloud site is NOT deleted.",
|
|
4228
|
+
"No prior project directory, browser history, workspace switch, or user-run command is required. YOU then call deploy with the exact nextAction arguments. Never ask the user to locate an old directory or run a CLI, and never recommend another hosting provider.",
|
|
4229
|
+
...allowanceNetworkReference ? [
|
|
4230
|
+
`Cloud-observed allowance network reference: ${allowanceNetworkReference}. This diagnostic reference came from the rejected deployment request; it is not the administrator process's public IP and does not grant site ownership.`
|
|
4231
|
+
] : []
|
|
4232
|
+
]
|
|
4233
|
+
});
|
|
4234
|
+
return presentDecision(decisions, call, "deploy", {
|
|
4061
4235
|
resultCode: "free_site_slot_selection_required",
|
|
4062
4236
|
summary,
|
|
4063
4237
|
data: {
|
|
@@ -4163,6 +4337,7 @@ function registerTools(server, baseCtx) {
|
|
|
4163
4337
|
server.registerTool(
|
|
4164
4338
|
"analyze",
|
|
4165
4339
|
{
|
|
4340
|
+
title: "Analyze project",
|
|
4166
4341
|
description: "Analyze the local project and decide whether it can be deployed as a static site. Detects the framework, the built static output directory (dist/build/out/...), missing index.html, SSR/API-route/database-runtime risks, SPA fallback needs, forbidden files (secrets, .env, archives, media) and size limits. Sakupa deploys ONLY prebuilt static output \u2014 never source, secrets or server code. Run this before deploy.",
|
|
4167
4342
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4168
4343
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
@@ -4178,8 +4353,8 @@ function registerTools(server, baseCtx) {
|
|
|
4178
4353
|
});
|
|
4179
4354
|
return textJson(
|
|
4180
4355
|
"site_analysis_completed",
|
|
4181
|
-
`Analysis of ${ctx.projectDir}
|
|
4182
|
-
Next action: ${analysis.suggestedNextAction}`,
|
|
4356
|
+
`Analysis of ${ctx.projectDir}`,
|
|
4357
|
+
`Next action: ${analysis.suggestedNextAction}`,
|
|
4183
4358
|
analysisSummary(analysis)
|
|
4184
4359
|
);
|
|
4185
4360
|
} catch (e) {
|
|
@@ -4190,6 +4365,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4190
4365
|
server.registerTool(
|
|
4191
4366
|
"deploy",
|
|
4192
4367
|
{
|
|
4368
|
+
title: "Deploy site",
|
|
4193
4369
|
description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://${previewHostPattern}) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscription-backed sites have no free-site expiry while the subscription remains active). Runs analyze first and refuses to upload source projects, secrets, .env files, archives, media or server code. The MCP process is locked to the current directory initialized by the no-argument init MCP tool; no tool argument can change that root. outputDir is a separate REQUIRED relative path supplied from the current project inspection. Never uploads anything when analysis says the project is not deployable.`,
|
|
4194
4370
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4195
4371
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
@@ -4221,7 +4397,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4221
4397
|
lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
4222
4398
|
})
|
|
4223
4399
|
},
|
|
4224
|
-
async (args, call) => {
|
|
4400
|
+
withDecisionReentry("deploy", async (args, call) => {
|
|
4225
4401
|
let releaseHandoffLock;
|
|
4226
4402
|
try {
|
|
4227
4403
|
const ctx = await withProjectDir(baseCtx, call);
|
|
@@ -4238,7 +4414,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4238
4414
|
outputDir: effectiveOutputDir,
|
|
4239
4415
|
...confirmation
|
|
4240
4416
|
};
|
|
4241
|
-
return
|
|
4417
|
+
return presentDecision(baseCtx.decisions, call, "deploy", {
|
|
4242
4418
|
resultCode: "publish_directory_change_confirmation_required",
|
|
4243
4419
|
summary: `This initialized project last published from "${recordedOutputDir}", but this request selected "${effectiveOutputDir}". Nothing was uploaded and the site was not changed. Show both paths to the user; only after explicit confirmation call deploy again with outputDirChangeConfirmed: true.`,
|
|
4244
4420
|
data: {
|
|
@@ -4321,7 +4497,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4321
4497
|
if (args.sakupaRelocationConfirmed !== true) {
|
|
4322
4498
|
const confirmation = { sakupaRelocationConfirmed: true };
|
|
4323
4499
|
const confirmArguments = { ...args, ...confirmation };
|
|
4324
|
-
return
|
|
4500
|
+
return presentDecision(baseCtx.decisions, call, "deploy", {
|
|
4325
4501
|
resultCode: "sakupa_relocation_confirmation_required",
|
|
4326
4502
|
summary: `A nested Sakupa project marker exists at ${candidateDir}/.sakupa, but the active MCP Root is ${ctx.projectDir}. Nothing was moved or deployed. Show both paths to the user; after confirmation retry deploy with sakupaRelocationConfirmed:true. Sakupa will preserve credentials and refuse conflicts.`,
|
|
4327
4503
|
data: {
|
|
@@ -4474,7 +4650,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4474
4650
|
if (!existing && args.reuseSiteUrl === void 0 && args.publicConfirmed !== true) {
|
|
4475
4651
|
const confirmation = { publicConfirmed: true };
|
|
4476
4652
|
const confirmArguments = { ...args, ...confirmation };
|
|
4477
|
-
return
|
|
4653
|
+
return presentDecision(baseCtx.decisions, call, "deploy", {
|
|
4478
4654
|
resultCode: "public_deployment_confirmation_required",
|
|
4479
4655
|
summary: `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Nothing has been uploaded or made public yet. The exact confirmation field is publicConfirmed: true.`,
|
|
4480
4656
|
data: {
|
|
@@ -4506,7 +4682,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4506
4682
|
if (args.reuseConfirmed !== true) {
|
|
4507
4683
|
const confirmation = { reuseConfirmed: true };
|
|
4508
4684
|
const confirmArguments = { ...args, publicConfirmed: true, ...confirmation };
|
|
4509
|
-
return
|
|
4685
|
+
return presentDecision(baseCtx.decisions, call, "deploy", {
|
|
4510
4686
|
resultCode: "free_site_reuse_confirmation_required",
|
|
4511
4687
|
summary: `Nothing was changed. Reusing ${args.reuseSiteUrl} will replace all online content at that URL with the current project, issue a fresh credential here, and revoke every previous credential automatically. No old directory is needed. Show these consequences and call deploy with reuseConfirmed:true only after the user explicitly selects this URL.`,
|
|
4512
4688
|
data: {
|
|
@@ -4638,7 +4814,13 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4638
4814
|
if (isSakupaError(error) && error.code === "rate_limited") {
|
|
4639
4815
|
const allowanceNetworkReference = allowanceNetworkReferenceFrom(error);
|
|
4640
4816
|
if (deviceSites.length > 0) {
|
|
4641
|
-
return freeSiteCreationBarrier(
|
|
4817
|
+
return freeSiteCreationBarrier(
|
|
4818
|
+
baseCtx.decisions,
|
|
4819
|
+
call,
|
|
4820
|
+
deviceSites,
|
|
4821
|
+
{ ...args },
|
|
4822
|
+
allowanceNetworkReference
|
|
4823
|
+
);
|
|
4642
4824
|
}
|
|
4643
4825
|
const networkReferenceText = allowanceNetworkReference ? ` Cloud-observed allowance network reference: ${allowanceNetworkReference}. This reference came from the rejected deployment request, not from the administrator process's public IP.` : "";
|
|
4644
4826
|
return text(
|
|
@@ -4680,15 +4862,30 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4680
4862
|
});
|
|
4681
4863
|
return text(
|
|
4682
4864
|
"site_published",
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4865
|
+
summaryMarkdown({
|
|
4866
|
+
title: `Site published: ${finalized2.url}`,
|
|
4867
|
+
lead: deploymentEnvironmentContext(ctx.apiBaseUrl) + `Project directory: ${ctx.projectDir}`,
|
|
4868
|
+
facts: [
|
|
4869
|
+
["Public URL", finalized2.url],
|
|
4870
|
+
["Project directory", ctx.projectDir],
|
|
4871
|
+
["Files uploaded", `${uploaded2} (${finalized2.totalBytes} bytes)`],
|
|
4872
|
+
[
|
|
4873
|
+
"Expiry deadline",
|
|
4874
|
+
finalized2.expiresAt ? timestampForAgent(finalized2.expiresAt) : void 0
|
|
4875
|
+
],
|
|
4876
|
+
["Credential path", ".sakupa/site.json"]
|
|
4877
|
+
],
|
|
4878
|
+
notes: [
|
|
4879
|
+
`This is a FREE temporary preview: it stays live for ${FREE_SITE_TTL_HOURS} hours. Deploying again or calling refresh extends the validity; while a subscription remains active, this URL stays live without the free-site expiry. This is conditional on the subscription remaining active: do NOT describe the site as permanent or long-term, and do NOT say the subscription is bound to the site.`,
|
|
4880
|
+
"The management credential was saved to the exact relative path .sakupa/site.json \u2014 preserve this complete path verbatim and never shorten it to site.json. Keep that file: it is the only way to manage this site.",
|
|
4881
|
+
...[credentialGitReminder(ctx.projectDir)].filter((line) => line.trim().length > 0)
|
|
4882
|
+
],
|
|
4883
|
+
next: ["`status`", "`subscribe` to keep the site online beyond the free period"],
|
|
4884
|
+
raw: finalized2.warnings.length > 0 ? `Warnings:
|
|
4885
|
+
\`\`\`json
|
|
4886
|
+
${JSON.stringify(finalized2.warnings, null, 2)}
|
|
4887
|
+
\`\`\`` : void 0
|
|
4888
|
+
}),
|
|
4692
4889
|
{
|
|
4693
4890
|
siteId: created.siteId,
|
|
4694
4891
|
shortId: created.shortId,
|
|
@@ -4776,18 +4973,43 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
4776
4973
|
}
|
|
4777
4974
|
return text(
|
|
4778
4975
|
handoffPerformed ? "free_site_slot_reassigned" : "site_updated",
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4976
|
+
summaryMarkdown({
|
|
4977
|
+
title: `Site updated: ${finalized.url}`,
|
|
4978
|
+
lead: deploymentEnvironmentContext(ctx.apiBaseUrl) + `Project directory: ${ctx.projectDir}`,
|
|
4979
|
+
facts: [
|
|
4980
|
+
["Public URL", finalized.url],
|
|
4981
|
+
["Project directory", ctx.projectDir],
|
|
4982
|
+
["Files uploaded", `${uploaded} (${finalized.totalBytes} bytes)`],
|
|
4983
|
+
["Mode", finalized.mode],
|
|
4984
|
+
[
|
|
4985
|
+
"Validity refreshed \u2014 expiry deadline",
|
|
4986
|
+
finalized.expiresAt ? timestampForAgent(finalized.expiresAt) : void 0
|
|
4987
|
+
]
|
|
4988
|
+
],
|
|
4989
|
+
notes: [
|
|
4990
|
+
...credentialRelocatedFrom.length > 0 ? [
|
|
4991
|
+
`Credential binding relocated from ${credentialRelocatedFrom.join(", ")} to ${ctx.projectDir}/.sakupa; the existing site was preserved.`
|
|
4992
|
+
] : [],
|
|
4993
|
+
...handoffPerformed ? [
|
|
4994
|
+
`Site handoff completed from the authenticated device list. The existing free-site URL stayed the same, the cloud site was NOT deleted, and its content was replaced. Sakupa issued a fresh project credential and revoked ${handoffRevokedCredentials} previous credential(s), so no old project can continue managing this URL.` + (handoffCleanup?.sourceCredentialRemoved ? " A matching obsolete local site.json was removed automatically." : "")
|
|
4995
|
+
] : [],
|
|
4996
|
+
...credentialRotationResumed ? [
|
|
4997
|
+
"A previously confirmed credential rotation was resumed safely before this deploy; every older credential is revoked."
|
|
4998
|
+
] : [],
|
|
4999
|
+
finalized.mode === "free" ? `Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh call. While a subscription remains active, the site stays live without this free-site expiry.` : "This site is subscription-backed and has no free-site expiry while the subscription remains active.",
|
|
5000
|
+
...credentialSecurity?.rotationRecommended ? [
|
|
5001
|
+
`Optional security recommendation: this management credential was created at ${timestampForAgent(credentialSecurity.credentialCreatedAt)} and is older than 7 days. The deploy SUCCEEDED and rotation is not required. Ask the user whether they want to rotate; call rotate without confirmed:true to show the exact revocation preview. Never rotate automatically.`
|
|
5002
|
+
] : []
|
|
5003
|
+
],
|
|
5004
|
+
next: [
|
|
5005
|
+
"`status`",
|
|
5006
|
+
...credentialSecurity?.rotationRecommended ? ["`rotate` (optional, preview first) if the user wants a fresh credential"] : []
|
|
5007
|
+
],
|
|
5008
|
+
raw: finalized.warnings.length > 0 ? `Warnings:
|
|
5009
|
+
\`\`\`json
|
|
5010
|
+
${JSON.stringify(finalized.warnings, null, 2)}
|
|
5011
|
+
\`\`\`` : void 0
|
|
5012
|
+
}),
|
|
4791
5013
|
{
|
|
4792
5014
|
siteId: existing.siteId,
|
|
4793
5015
|
url: finalized.url,
|
|
@@ -4841,11 +5063,12 @@ Optional security recommendation: this management credential was created at ${ti
|
|
|
4841
5063
|
} finally {
|
|
4842
5064
|
releaseHandoffLock?.();
|
|
4843
5065
|
}
|
|
4844
|
-
}
|
|
5066
|
+
})
|
|
4845
5067
|
);
|
|
4846
5068
|
server.registerTool(
|
|
4847
5069
|
"refresh",
|
|
4848
5070
|
{
|
|
5071
|
+
title: "Refresh free site",
|
|
4849
5072
|
description: "Refresh the validity of the free temporary site WITHOUT uploading content. Uses the local credential in .sakupa/site.json. Subscription-backed sites have no free-site expiry while the subscription remains active and need no refresh.",
|
|
4850
5073
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4851
5074
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -4867,8 +5090,18 @@ Optional security recommendation: this management credential was created at ${ti
|
|
|
4867
5090
|
}
|
|
4868
5091
|
return text(
|
|
4869
5092
|
"site_refreshed",
|
|
4870
|
-
|
|
4871
|
-
|
|
5093
|
+
summaryMarkdown({
|
|
5094
|
+
title: "Site validity refreshed",
|
|
5095
|
+
facts: [
|
|
5096
|
+
["Project directory", ctx.projectDir],
|
|
5097
|
+
["New expiry", timestampForAgent(res.expiresAt)]
|
|
5098
|
+
],
|
|
5099
|
+
notes: [
|
|
5100
|
+
"NO content was uploaded or changed by this call \u2014 to publish new or edited files, run deploy.",
|
|
5101
|
+
`Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`
|
|
5102
|
+
],
|
|
5103
|
+
next: ["`status`", "`deploy` to publish changed files"]
|
|
5104
|
+
}),
|
|
4872
5105
|
{ siteId: site.siteId, expiresAt: res.expiresAt, projectDir: ctx.projectDir }
|
|
4873
5106
|
);
|
|
4874
5107
|
} catch (e) {
|
|
@@ -4879,6 +5112,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
4879
5112
|
server.registerTool(
|
|
4880
5113
|
"status",
|
|
4881
5114
|
{
|
|
5115
|
+
title: "Site status",
|
|
4882
5116
|
description: "Show the current status of this project's Sakupa site: URL, mode (free/paid), expiry, custom domains, size, last deployment and warnings. For a paid site this tool also automatically returns the complete authoritative billing snapshot; users never need to know or name a separate billing tool to get accurate subscription information.",
|
|
4883
5117
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4884
5118
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -4894,7 +5128,12 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
4894
5128
|
const binding = res.pendingDomainBinding ? await describePendingBinding(ctx.client, site.credential, res.pendingDomainBinding) : void 0;
|
|
4895
5129
|
return textJson(
|
|
4896
5130
|
"status_returned",
|
|
4897
|
-
billing ? `Site status
|
|
5131
|
+
billing ? `Site status for ${res.url ?? res.siteId} with AUTHORITATIVE BILLING SNAPSHOT` : `Site status for ${res.url ?? res.siteId}`,
|
|
5132
|
+
[
|
|
5133
|
+
`Mode: ${res.mode} \xB7 Serving: ${res.servingMode} \xB7 Status: ${res.status}` + (res.expiresAt ? ` \xB7 Free expiry: ${timestampForAgent(res.expiresAt)}` : ""),
|
|
5134
|
+
billing ? "When answering any subscription question, use the nested billing object and report the current plan, scheduled renewal or cancellation, effective time, current entitlement, billing period, usage state and one-time carry when present." : "",
|
|
5135
|
+
binding?.note ?? ""
|
|
5136
|
+
].filter(Boolean).join("\n"),
|
|
4898
5137
|
{
|
|
4899
5138
|
...res,
|
|
4900
5139
|
projectDir: ctx.projectDir,
|
|
@@ -4910,6 +5149,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
4910
5149
|
server.registerTool(
|
|
4911
5150
|
"subscribe",
|
|
4912
5151
|
{
|
|
5152
|
+
title: "Subscribe (Stripe Checkout)",
|
|
4913
5153
|
description: `Create a Stripe Checkout link that subscribes THIS site to a Sakupa Hosting monthly plan (${planCatalog()}). While the subscription remains active, its ${previewHostPattern} URL stays live without the free 24-hour expiry. Binding a custom domain afterwards (bind) is an optional included extra and requires DNS control of that domain. Owner-only: requires this project's site credential (.sakupa/site.json) \u2014 deploy first. If the site outgrows its plan, Sakupa shows an over-limit notice and never changes billing automatically. The owner can explicitly choose another plan through Stripe Customer Portal. Card details are entered only on the Stripe-hosted page \u2014 never through the AI tool. Opening and completing Stripe Checkout is the final subscription confirmation.`,
|
|
4914
5154
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4915
5155
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -4933,11 +5173,23 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
4933
5173
|
);
|
|
4934
5174
|
return text(
|
|
4935
5175
|
"subscription_checkout_ready",
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
5176
|
+
summaryMarkdown({
|
|
5177
|
+
title: "Stripe Checkout link \u2014 Sakupa Hosting for this site",
|
|
5178
|
+
lead: `Present this exact URL to the user: ${res.checkoutUrl}`,
|
|
5179
|
+
facts: [
|
|
5180
|
+
["Plan", `${res.plan} plan, JPY ${res.monthlyPriceJpy}/month (Japanese yen)`],
|
|
5181
|
+
["Checkout URL", res.checkoutUrl],
|
|
5182
|
+
["Final confirmation", "Stripe-hosted checkout page"]
|
|
5183
|
+
],
|
|
5184
|
+
steps: [
|
|
5185
|
+
"Open this link in a browser to subscribe. Card data is entered only on the Stripe-hosted page \u2014 never give card numbers, passwords or security codes to the AI tool."
|
|
5186
|
+
],
|
|
5187
|
+
notes: [
|
|
5188
|
+
"Once Stripe confirms payment and Sakupa synchronizes the subscription, the current URL stays live while that subscription remains active.",
|
|
5189
|
+
"Binding a custom domain (bind) is optional and still requires DNS verification."
|
|
5190
|
+
],
|
|
5191
|
+
next: ["`billing` after the user completes checkout"]
|
|
5192
|
+
}),
|
|
4941
5193
|
{
|
|
4942
5194
|
siteId: res.siteId,
|
|
4943
5195
|
plan: res.plan,
|
|
@@ -4956,6 +5208,7 @@ Once Stripe confirms payment and Sakupa synchronizes the subscription, the curre
|
|
|
4956
5208
|
server.registerTool(
|
|
4957
5209
|
"bind",
|
|
4958
5210
|
{
|
|
5211
|
+
title: "Bind custom domain",
|
|
4959
5212
|
description: `Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the subscription-backed ${previewHostPattern} URL keeps working alongside it while the subscription is active. The binding unit is the APEX domain: binding example.com reserves routes for example.com and www.example.com, but ONLY www is required and judged for activation; the naked apex is optional because many DNS providers cannot point it. One apex TXT verification covers both. A site has one FINAL apex domain; starting a different apex begins a zero-downtime switch and the previous domain remains until the new www is live. The www CNAME must remain while bound. Requires an ACTIVE subscription (subscribe). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership, and bindings are ALWAYS challengeable: whoever proves CURRENT DNS control takes the domain, even from an existing binding (the displaced site keeps its subscription, content and subscription-backed Sakupa URL). Unverified requests expire after 72 hours. Call again with action "status" to check progress.`,
|
|
4960
5213
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
4961
5214
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
@@ -4990,14 +5243,25 @@ Once Stripe confirms payment and Sakupa synchronizes the subscription, the curre
|
|
|
4990
5243
|
const customerRecheckInstruction = `The customer cannot know whether the certificate is ready. Never use conditional readiness wording or ask the customer to decide the provider state. Tell the customer: "You do not need to judge readiness. After about one minute, reply: check domain status. I will check it once." The AI, not the customer, calls bind status exactly once. During this zero-downtime transition, the previously active domain may still serve. Once this binding becomes active, Sakupa retains only the last bound domain unit: ${apex2} and www.${apex2}.`;
|
|
4991
5244
|
return text(
|
|
4992
5245
|
res2.bindingStatus === "active" ? "domain_binding_active" : res2.bindingStatus === "provisioning" ? "domain_binding_provisioning" : "domain_verification_pending",
|
|
4993
|
-
|
|
4994
|
-
${res2.
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5246
|
+
summaryMarkdown({
|
|
5247
|
+
title: `Domain binding status for ${apex2}: ${res2.status}`,
|
|
5248
|
+
lead: res2.message,
|
|
5249
|
+
facts: [
|
|
5250
|
+
["Ownership verification", res2.status],
|
|
5251
|
+
["Binding status", res2.bindingStatus],
|
|
5252
|
+
["Provisioning phase", res2.provisioningPhase],
|
|
5253
|
+
["Required serving record", `www.${apex2} CNAME \u2192 ${res2.servingTarget}`],
|
|
5254
|
+
["Live for the customer", res2.bindingStatus === "active" ? "yes" : "not yet"]
|
|
5255
|
+
],
|
|
5256
|
+
notes: [
|
|
5257
|
+
`The serving CNAME www.${apex2} \u2192 ${res2.servingTarget} must remain for as long as this domain is bound.`,
|
|
5258
|
+
...manualProviderRecheckRequired ? [customerRecheckInstruction] : []
|
|
5259
|
+
],
|
|
5260
|
+
raw: renderChecklistBlock(
|
|
5261
|
+
diag,
|
|
5262
|
+
"Fix any [MISSING]/[FIX] lines above, then re-run bind status; if still failing after the attempts below, show the user this checklist."
|
|
5263
|
+
)
|
|
5264
|
+
}),
|
|
5001
5265
|
{
|
|
5002
5266
|
verificationId: res2.verificationId,
|
|
5003
5267
|
status: res2.status,
|
|
@@ -5052,17 +5316,28 @@ ${customerRecheckInstruction}` : "") + "\n\n" + renderChecklistBlock(
|
|
|
5052
5316
|
` : "";
|
|
5053
5317
|
return text(
|
|
5054
5318
|
"domain_verification_started",
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
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
|
|
5058
|
-
|
|
5059
|
-
STEP 1 of 2 \u2014
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5319
|
+
summaryMarkdown({
|
|
5320
|
+
title: `Domain binding started for ${apex}`,
|
|
5321
|
+
lead: switchNotice + `Routes reserved: ${res.includedHostnames.join(", ")}. Only www.${apex} is required to go live; the naked domain is optional. This is a STEP-BY-STEP setup \u2014 give the user ONE record at a time so they do not get overwhelmed and give up.`,
|
|
5322
|
+
facts: [
|
|
5323
|
+
["STEP 1 of 2 \u2014 record type", "TXT"],
|
|
5324
|
+
["TXT host (short form)", txtShort],
|
|
5325
|
+
["TXT value", res.verificationRecord.value],
|
|
5326
|
+
["Full record name", res.verificationRecord.name],
|
|
5327
|
+
["Challenge expires", "after 72 hours"]
|
|
5328
|
+
],
|
|
5329
|
+
steps: [
|
|
5330
|
+
`STEP 1 of 2 \u2014 prove ownership. Add ONE record: TXT host: ${txtShort} value: ${res.verificationRecord.value}`,
|
|
5331
|
+
'Tell the AI when the TXT record is set; it then runs bind "status", which verifies ownership and hands back STEP 2 \u2014 a SINGLE www CNAME.'
|
|
5332
|
+
],
|
|
5333
|
+
notes: [
|
|
5334
|
+
`Host is the SHORT form: most panels append the domain automatically (the saved record must NOT show ${apex} twice in one name).`,
|
|
5335
|
+
"Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins.",
|
|
5336
|
+
"There are NO certificate TXT records; HTTPS validates automatically over the www CNAME. That CNAME must remain for as long as the domain stays bound to Sakupa.",
|
|
5337
|
+
'Each "status" checks the previous step and, unless something is misconfigured, advances to the next \u2014 so run it whenever the user reports a step done, NOT on a timer. Any later session can resume with action "status" alone; the verificationId is optional.'
|
|
5338
|
+
],
|
|
5339
|
+
next: ['`bind` with action "status" after the user reports the TXT record is set']
|
|
5340
|
+
}),
|
|
5066
5341
|
{
|
|
5067
5342
|
verificationId: res.verificationId,
|
|
5068
5343
|
apexDomain: apex,
|
|
@@ -5095,6 +5370,7 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
|
|
|
5095
5370
|
server.registerTool(
|
|
5096
5371
|
"billing",
|
|
5097
5372
|
{
|
|
5373
|
+
title: "Billing snapshot",
|
|
5098
5374
|
description: "Return the sole authoritative source for this site's hosting subscription: current plan, next renewal plan or cancellation, effective time, payment state, current paid entitlement, reconciled paid usage or current free-site fair-use telemetry, estimated usage tier, bound custom domains and risks. Owner-only (uses the credential in .sakupa/site.json).",
|
|
5099
5375
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5100
5376
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -5128,9 +5404,14 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
|
|
|
5128
5404
|
res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
|
|
5129
5405
|
res.risks.pastDue ? "ATTENTION: renewal payment failing \u2014 update the payment method (portal). Serving continues while Stripe retries; if Stripe gives up, the site reverts to free." : void 0
|
|
5130
5406
|
].filter((l) => l !== void 0);
|
|
5131
|
-
return textJson(
|
|
5407
|
+
return textJson(
|
|
5408
|
+
"billing_returned",
|
|
5409
|
+
`AUTHORITATIVE BILLING SNAPSHOT for site ${res.siteId} (mode: ${res.mode})`,
|
|
5410
|
+
`${lines.slice(1).map((line) => `- ${line}`).join("\n")}
|
|
5132
5411
|
|
|
5133
|
-
Full status:`,
|
|
5412
|
+
Full status:`,
|
|
5413
|
+
res
|
|
5414
|
+
);
|
|
5134
5415
|
} catch (e) {
|
|
5135
5416
|
return toolError(e);
|
|
5136
5417
|
}
|
|
@@ -5139,6 +5420,7 @@ Full status:`, res);
|
|
|
5139
5420
|
server.registerTool(
|
|
5140
5421
|
"portal",
|
|
5141
5422
|
{
|
|
5423
|
+
title: "Billing portal (Stripe)",
|
|
5142
5424
|
description: "Open the Stripe-hosted billing portal for this site: update the payment method, view invoices, or cancel the subscription. All billing operations happen on the Stripe-hosted page \u2014 never inside the AI tool. With .sakupa/site.json, this opens the site-specific portal. Without the local credential, this returns Stripe's public no-code Customer Portal login page. The customer enters the checkout email and confirms a one-time passcode sent by Stripe. This never restores Sakupa site authority.",
|
|
5143
5425
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5144
5426
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -5156,7 +5438,12 @@ Full status:`, res);
|
|
|
5156
5438
|
schemaVersion: 1,
|
|
5157
5439
|
outcome: "waiting_user",
|
|
5158
5440
|
resultCode: "site_billing_portal_ready",
|
|
5159
|
-
summary:
|
|
5441
|
+
summary: summaryMarkdown({
|
|
5442
|
+
title: "Stripe customer portal link ready",
|
|
5443
|
+
lead: `Short-lived Stripe customer portal link created for this site: ${res2.portalUrl}`,
|
|
5444
|
+
notes: ["Any change still happens only on the Stripe-hosted page."],
|
|
5445
|
+
next: ["`billing` after the user finishes on Stripe"]
|
|
5446
|
+
}),
|
|
5160
5447
|
data: { scope: args.scope, portalUrl: res2.portalUrl },
|
|
5161
5448
|
userAction: {
|
|
5162
5449
|
type: "open_url",
|
|
@@ -5172,7 +5459,14 @@ Full status:`, res);
|
|
|
5172
5459
|
schemaVersion: 1,
|
|
5173
5460
|
outcome: "waiting_user",
|
|
5174
5461
|
resultCode: "public_billing_recovery_portal_ready",
|
|
5175
|
-
summary:
|
|
5462
|
+
summary: summaryMarkdown({
|
|
5463
|
+
title: "Stripe public billing login page",
|
|
5464
|
+
lead: `Stripe public email-OTP login page: ${res.portalUrl}`,
|
|
5465
|
+
notes: [
|
|
5466
|
+
"It uses a one-time passcode, does not recover the Sakupa key, and grants no site authority.",
|
|
5467
|
+
"When one email has several Customers, Stripe may open only the most recently created usable record."
|
|
5468
|
+
]
|
|
5469
|
+
}),
|
|
5176
5470
|
data: {
|
|
5177
5471
|
scope: args.scope,
|
|
5178
5472
|
portalUrl: res.portalUrl,
|
|
@@ -5195,6 +5489,7 @@ Full status:`, res);
|
|
|
5195
5489
|
server.registerTool(
|
|
5196
5490
|
"recover",
|
|
5197
5491
|
{
|
|
5492
|
+
title: "Recover site",
|
|
5198
5493
|
description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Recovery is resumable: start stores local pending state; complete installs and writes the new .sakupa/site.json credential BEFORE requesting content; download uses that credential to reissue an archive and safely extract it into the explicitly selected outputDir without repeating DNS.",
|
|
5199
5494
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5200
5495
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
@@ -5208,7 +5503,7 @@ Full status:`, res);
|
|
|
5208
5503
|
preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
|
|
5209
5504
|
})
|
|
5210
5505
|
},
|
|
5211
|
-
async (args, call) => {
|
|
5506
|
+
withDecisionReentry("recover", async (args, call) => {
|
|
5212
5507
|
try {
|
|
5213
5508
|
const ctx = await withProjectDir(baseCtx, call);
|
|
5214
5509
|
if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
|
|
@@ -5416,7 +5711,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
5416
5711
|
...revokeArguments,
|
|
5417
5712
|
preserveExistingCredentials: true
|
|
5418
5713
|
};
|
|
5419
|
-
return
|
|
5714
|
+
return presentDecision(baseCtx.decisions, call, "recover", {
|
|
5420
5715
|
resultCode: "domain_recovery_ready",
|
|
5421
5716
|
summary: "DNS control is verified and recovery is ready to complete. Nothing was completed yet. The user must choose whether previous site credentials remain valid.",
|
|
5422
5717
|
data: {
|
|
@@ -5551,11 +5846,12 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5551
5846
|
} catch (e) {
|
|
5552
5847
|
return toolError(e);
|
|
5553
5848
|
}
|
|
5554
|
-
}
|
|
5849
|
+
})
|
|
5555
5850
|
);
|
|
5556
5851
|
server.registerTool(
|
|
5557
5852
|
"support",
|
|
5558
5853
|
{
|
|
5854
|
+
title: "Support ticket",
|
|
5559
5855
|
description: "Create a Sakupa support ticket for billing, payment, refund review, domain verification, deployment, serving or other issues the MCP cannot solve automatically. Do not include secrets, credentials or card data in the description.",
|
|
5560
5856
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5561
5857
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -5579,7 +5875,14 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5579
5875
|
});
|
|
5580
5876
|
return text(
|
|
5581
5877
|
"support_ticket_created",
|
|
5582
|
-
|
|
5878
|
+
summaryMarkdown({
|
|
5879
|
+
title: "Support ticket created",
|
|
5880
|
+
facts: [
|
|
5881
|
+
["Ticket", res.ticketId],
|
|
5882
|
+
["Status", res.status]
|
|
5883
|
+
],
|
|
5884
|
+
notes: ["Wait for the Sakupa support follow-up; no further tool call is needed."]
|
|
5885
|
+
}),
|
|
5583
5886
|
{ ticketId: res.ticketId, status: res.status }
|
|
5584
5887
|
);
|
|
5585
5888
|
} catch (e) {
|
|
@@ -5590,6 +5893,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5590
5893
|
server.registerTool(
|
|
5591
5894
|
"report",
|
|
5592
5895
|
{
|
|
5896
|
+
title: "Bug report",
|
|
5593
5897
|
description: "LAST RESORT after help explicitly returns reportRecommended:true. Prepare and submit a sanitized product bug report using helpAuthorization from that diagnosis. Only whitelisted structured diagnostics are sent (tool name, error code/message, site id, bound domain, deployment id, timestamps, client/MCP version, request id) \u2014 NEVER file contents, source code, secrets, .env values or credentials. Without confirmSubmit: true the exact payload is shown for user review and nothing is submitted.",
|
|
5594
5898
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5595
5899
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -5611,7 +5915,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5611
5915
|
confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
|
|
5612
5916
|
})
|
|
5613
5917
|
},
|
|
5614
|
-
async (args, call) => {
|
|
5918
|
+
withDecisionReentry("report", async (args, call) => {
|
|
5615
5919
|
try {
|
|
5616
5920
|
requireReportAuthorization(baseCtx, args.helpAuthorization, args.toolName);
|
|
5617
5921
|
const ctx = await optionalProjectContext(baseCtx, call);
|
|
@@ -5641,7 +5945,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5641
5945
|
const contactNote = args.contactEmail !== void 0 ? "note that their contact email is attached for follow-up. " : "ASK THEM ONCE whether they want to attach a contact email for follow-up (optional \u2014 omit if declined; include it as contactEmail when they do). ";
|
|
5642
5946
|
const confirmation = { confirmSubmit: true };
|
|
5643
5947
|
const confirmArguments = { ...args, ...confirmation };
|
|
5644
|
-
return
|
|
5948
|
+
return presentDecision(baseCtx.decisions, call, "report", {
|
|
5645
5949
|
resultCode: "bug_report_preview_ready",
|
|
5646
5950
|
outcome: "preview",
|
|
5647
5951
|
summary: `Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Show it to the user, and ${contactNote}Exact payload:
|
|
@@ -5683,7 +5987,7 @@ Summary: ${res.sanitizedSummary}`,
|
|
|
5683
5987
|
} catch (e) {
|
|
5684
5988
|
return toolError(e);
|
|
5685
5989
|
}
|
|
5686
|
-
}
|
|
5990
|
+
})
|
|
5687
5991
|
);
|
|
5688
5992
|
}
|
|
5689
5993
|
|
|
@@ -5691,8 +5995,10 @@ Summary: ${res.sanitizedSummary}`,
|
|
|
5691
5995
|
import {
|
|
5692
5996
|
CLIENT_CAPABILITIES_META_KEY,
|
|
5693
5997
|
McpServer,
|
|
5998
|
+
createRequestStateCodec,
|
|
5694
5999
|
inputResponse
|
|
5695
6000
|
} from "@modelcontextprotocol/server";
|
|
6001
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
5696
6002
|
|
|
5697
6003
|
// src/tools/billing.ts
|
|
5698
6004
|
import { z as z3 } from "zod";
|
|
@@ -5700,6 +6006,7 @@ function registerBillingTools(server, baseCtx) {
|
|
|
5700
6006
|
server.registerTool(
|
|
5701
6007
|
"plans",
|
|
5702
6008
|
{
|
|
6009
|
+
title: "Hosting plan catalog",
|
|
5703
6010
|
description: "Return the authoritative Sakupa monthly plan catalog, exact limits, prices, catalog version and plan-change billing rules. This is read-only and does not require a site.",
|
|
5704
6011
|
inputSchema: z3.object({}),
|
|
5705
6012
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
@@ -5712,7 +6019,21 @@ function registerBillingTools(server, baseCtx) {
|
|
|
5712
6019
|
schemaVersion: 1,
|
|
5713
6020
|
outcome: "completed",
|
|
5714
6021
|
resultCode: "billing_catalog_returned",
|
|
5715
|
-
summary:
|
|
6022
|
+
summary: summaryMarkdown({
|
|
6023
|
+
title: `Sakupa monthly plans (catalog ${catalog.catalogVersion})`,
|
|
6024
|
+
lead: `Returned ${catalog.plans.length} monthly plans; the Stripe-hosted page is the final confirmation surface for payment and plan changes. Prices are in JPY (Japanese yen).`,
|
|
6025
|
+
raw: [
|
|
6026
|
+
"| Plan | Rank | JPY / month | Storage (bytes) | Transfer (bytes) | Requests |",
|
|
6027
|
+
"|---|---|---|---|---|---|",
|
|
6028
|
+
...catalog.plans.map(
|
|
6029
|
+
(plan) => `| ${plan.id} | ${plan.rank} | ${plan.monthlyPriceJpy} | ${plan.limits.storageBytes} | ${plan.limits.transferBytes} | ${plan.limits.requests} |`
|
|
6030
|
+
)
|
|
6031
|
+
].join("\n"),
|
|
6032
|
+
notes: [
|
|
6033
|
+
`Upgrades bill immediately at full price (${catalog.upgradeChargeTiming}); downgrades take effect at ${catalog.downgradeEffectiveTiming}; unused transfer carries once (${catalog.upgradeTransferCarry}).`
|
|
6034
|
+
],
|
|
6035
|
+
next: ["`subscribe` for a first subscription", "`change` for an existing subscription"]
|
|
6036
|
+
}),
|
|
5716
6037
|
data: { catalog },
|
|
5717
6038
|
nextActions: [{ tool: "subscribe", allowed: true }]
|
|
5718
6039
|
});
|
|
@@ -5724,6 +6045,7 @@ function registerBillingTools(server, baseCtx) {
|
|
|
5724
6045
|
server.registerTool(
|
|
5725
6046
|
"change",
|
|
5726
6047
|
{
|
|
6048
|
+
title: "Change subscription (Stripe)",
|
|
5727
6049
|
description: "Create one Stripe-hosted subscription-management link. The user chooses the plan or period-end cancellation on Stripe; Sakupa never infers intent from the conversation. Creating the link does not change billing.",
|
|
5728
6050
|
inputSchema: z3.object({
|
|
5729
6051
|
operationId: z3.string().min(1)
|
|
@@ -5744,7 +6066,25 @@ function registerBillingTools(server, baseCtx) {
|
|
|
5744
6066
|
outcome: "waiting_user",
|
|
5745
6067
|
resultCode: "stripe_subscription_management_required",
|
|
5746
6068
|
operationId: args.operationId,
|
|
5747
|
-
summary:
|
|
6069
|
+
summary: summaryMarkdown({
|
|
6070
|
+
title: "Stripe subscription-management link ready",
|
|
6071
|
+
lead: `Stripe subscription-management link (present this exact URL to the user): ${result.portalUrl}`,
|
|
6072
|
+
facts: [
|
|
6073
|
+
["Subscription changed", "NO \u2014 nothing changes until the user confirms on Stripe"],
|
|
6074
|
+
[
|
|
6075
|
+
"Plan order (lowest \u2192 highest)",
|
|
6076
|
+
Array.isArray(result.planOrder) ? result.planOrder.join(" \u2192 ") : void 0
|
|
6077
|
+
]
|
|
6078
|
+
],
|
|
6079
|
+
steps: [
|
|
6080
|
+
"Open the link and choose Water, Personal, Share, Business, or period-end cancellation on the Stripe-hosted page."
|
|
6081
|
+
],
|
|
6082
|
+
notes: [
|
|
6083
|
+
"After Stripe confirmation, upgrades start a new billing cycle immediately at full price; downgrades and cancellation take effect at the current period end.",
|
|
6084
|
+
"The authoritative plan order from lowest to highest is Water, Personal, Share, Business; never describe a lower-ranked plan as an upgrade."
|
|
6085
|
+
],
|
|
6086
|
+
next: ["`billing` after the user finishes on Stripe"]
|
|
6087
|
+
}),
|
|
5748
6088
|
data: { portalUrl: result.portalUrl, result },
|
|
5749
6089
|
userAction: {
|
|
5750
6090
|
type: "open_url",
|
|
@@ -5817,7 +6157,7 @@ var TOOL_MANUALS = {
|
|
|
5817
6157
|
init: {
|
|
5818
6158
|
purpose: "Initialize the active IDE workspace as one Sakupa project.",
|
|
5819
6159
|
sideEffects: "Creates only .sakupa/project.json locally; no API call, site or charge.",
|
|
5820
|
-
preconditions: "Exactly one usable MCP workspace Root. If
|
|
6160
|
+
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.",
|
|
5821
6161
|
parameterNames: [],
|
|
5822
6162
|
parameters: "No parameters and no path argument.",
|
|
5823
6163
|
warnings: [
|
|
@@ -6015,6 +6355,7 @@ function registerHelpTools(server, baseCtx) {
|
|
|
6015
6355
|
server.registerTool(
|
|
6016
6356
|
"init",
|
|
6017
6357
|
{
|
|
6358
|
+
title: "Initialize project",
|
|
6018
6359
|
description: "Initialize the active MCP workspace Root as a Sakupa project. Takes no path argument, creates only .sakupa/project.json at that exact Root, preserves site/recovery state, makes no API call and is idempotent. If MCP Roots are unavailable, call help; the AI may then use the no-argument CLI init itself.",
|
|
6019
6360
|
inputSchema: z4.object({}),
|
|
6020
6361
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
@@ -6033,7 +6374,16 @@ function registerHelpTools(server, baseCtx) {
|
|
|
6033
6374
|
schemaVersion: 1,
|
|
6034
6375
|
outcome: "completed",
|
|
6035
6376
|
resultCode: "project_initialized",
|
|
6036
|
-
summary:
|
|
6377
|
+
summary: summaryMarkdown({
|
|
6378
|
+
title: "Sakupa project initialized",
|
|
6379
|
+
lead: `Initialized and verified at the active workspace Root: ${ctx.projectDir}. No cloud site was created and no charge occurred.`,
|
|
6380
|
+
facts: [
|
|
6381
|
+
["Project root", ctx.projectDir],
|
|
6382
|
+
[".sakupa directory", sakupaDirectory],
|
|
6383
|
+
["Binding source", ctx.bindingSource]
|
|
6384
|
+
],
|
|
6385
|
+
next: ["`analyze`, then `deploy` with the exact relative outputDir"]
|
|
6386
|
+
}),
|
|
6037
6387
|
data: {
|
|
6038
6388
|
projectRoot: ctx.projectDir,
|
|
6039
6389
|
sakupaDirectory,
|
|
@@ -6055,6 +6405,7 @@ function registerHelpTools(server, baseCtx) {
|
|
|
6055
6405
|
server.registerTool(
|
|
6056
6406
|
"help",
|
|
6057
6407
|
{
|
|
6408
|
+
title: "Help and diagnosis",
|
|
6058
6409
|
description: "FIRST troubleshooting tool for every Sakupa difficulty. With topic diagnose (default), inspect MCP Roots, cwd, binding and local state without requiring a project or calling the API. Use overview, terminology, or a tool name for complete usage, side effects, parameters and warnings. Only recommend report when help explicitly returns reportRecommended:true.",
|
|
6059
6410
|
inputSchema: z4.object({
|
|
6060
6411
|
topic: z4.enum(HELP_TOPICS).optional().default("diagnose"),
|
|
@@ -6082,7 +6433,25 @@ function registerHelpTools(server, baseCtx) {
|
|
|
6082
6433
|
schemaVersion: 1,
|
|
6083
6434
|
outcome: "completed",
|
|
6084
6435
|
resultCode: "help_overview",
|
|
6085
|
-
summary:
|
|
6436
|
+
summary: summaryMarkdown({
|
|
6437
|
+
title: "Sakupa tool overview",
|
|
6438
|
+
lead: "Sakupa tool overview and parameter names returned.",
|
|
6439
|
+
raw: [
|
|
6440
|
+
"| Tool | Purpose | Parameters |",
|
|
6441
|
+
"|---|---|---|",
|
|
6442
|
+
...TOOL_TOPICS.map(
|
|
6443
|
+
(tool) => `| \`${tool}\` | ${TOOL_MANUALS[tool].purpose} | ${TOOL_MANUALS[tool].parameterNames.join(", ") || "\u2014"} |`
|
|
6444
|
+
)
|
|
6445
|
+
].join("\n"),
|
|
6446
|
+
notes: [
|
|
6447
|
+
"Site handoff moves an existing free URL to the current project and replaces its credential as a safety consequence; credential rotation changes the credential in place solely for security. Both revoke prior values.",
|
|
6448
|
+
"When a result contains decision, present every option, select none by default, and copy only the user's selected option nextAction exactly.",
|
|
6449
|
+
'Use help topic:"terminology" for every site/credential distinction.'
|
|
6450
|
+
],
|
|
6451
|
+
next: [
|
|
6452
|
+
'`help` with topic:"diagnose" on any failure \u2014 before retrying, support or report'
|
|
6453
|
+
]
|
|
6454
|
+
}),
|
|
6086
6455
|
data: {
|
|
6087
6456
|
tools: catalog,
|
|
6088
6457
|
toolOrder: TOOL_TOPICS,
|
|
@@ -6117,13 +6486,20 @@ function registerHelpTools(server, baseCtx) {
|
|
|
6117
6486
|
schemaVersion: 1,
|
|
6118
6487
|
outcome: "completed",
|
|
6119
6488
|
resultCode: "help_tool_manual",
|
|
6120
|
-
summary:
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6489
|
+
summary: summaryMarkdown({
|
|
6490
|
+
title: `${args.topic}: ${manual.purpose}`,
|
|
6491
|
+
facts: [
|
|
6492
|
+
["Side effects", manual.sideEffects],
|
|
6493
|
+
["Preconditions", manual.preconditions],
|
|
6494
|
+
["Parameters", manual.parameters],
|
|
6495
|
+
["Parameter names", manual.parameterNames.join(", ") || "(none)"]
|
|
6496
|
+
],
|
|
6497
|
+
notes: [
|
|
6498
|
+
...manual.warnings,
|
|
6499
|
+
...terminologyText.length > 0 ? [`Terminology: ${terminologyText}`] : []
|
|
6500
|
+
],
|
|
6501
|
+
next: [manual.nextStep]
|
|
6502
|
+
}),
|
|
6127
6503
|
data: { tool: args.topic, ...manual, relatedTerminology },
|
|
6128
6504
|
nextActions: []
|
|
6129
6505
|
});
|
|
@@ -6171,7 +6547,23 @@ Terminology: ${terminologyText}` : ""),
|
|
|
6171
6547
|
}
|
|
6172
6548
|
] : credentialRotationState === "pending" ? [{ tool: "rotate", allowed: true, reasonCode: "resume_confirmed_rotation" }] : diagnosis.diagnosisCode === "workspace_not_initialized" ? [{ tool: "init", allowed: true, reasonCode: "initialize_active_root" }] : [];
|
|
6173
6549
|
const rotationGuidance = credentialRotationState === "pending" ? "A previously confirmed credential rotation is pending; call rotate with no arguments to resume it. The candidate credential is intentionally hidden." : credentialRotationState === "corrupted" ? "The local credential rotation journal is damaged. Preserve .sakupa/rotation.json, do not print, edit or delete it, and do not retry deploy or rotate until the file is recovered from a trusted backup or Sakupa support confirms the recovery path." : "";
|
|
6174
|
-
const summary =
|
|
6550
|
+
const summary = summaryMarkdown({
|
|
6551
|
+
title: `Help diagnosis: ${diagnosis.diagnosisCode}`,
|
|
6552
|
+
lead: diagnosis.guidance,
|
|
6553
|
+
facts: [
|
|
6554
|
+
["MCP version", MCP_VERSION],
|
|
6555
|
+
["Project marker", marker.kind],
|
|
6556
|
+
["Site binding", site.kind],
|
|
6557
|
+
["Recovery state", recoveryState],
|
|
6558
|
+
["Credential rotation", credentialRotationState],
|
|
6559
|
+
["Report recommended", reportRecommended ? "yes (last resort)" : "no"]
|
|
6560
|
+
],
|
|
6561
|
+
notes: [
|
|
6562
|
+
...rotationGuidance ? [rotationGuidance] : [],
|
|
6563
|
+
reportRecommended ? diagnosis.diagnosisCode === "project_bound" ? "Local project binding is healthy but the failure is an unclassified internal error. report is now available as the last resort; preview it before submission." : "The MCP Roots request itself failed with an unclassified internal error. report is now available as the last resort; preview it before submission." : "Do not submit report for this diagnosis; follow the guidance and retry help."
|
|
6564
|
+
],
|
|
6565
|
+
next: nextActions.map((action) => `\`${action.tool}\` (${action.reasonCode})`)
|
|
6566
|
+
});
|
|
6175
6567
|
return structuredToolResult({
|
|
6176
6568
|
schemaVersion: 1,
|
|
6177
6569
|
outcome: diagnosis.diagnosisCode === "project_bound" ? "completed" : "blocked",
|
|
@@ -6206,6 +6598,7 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6206
6598
|
server.registerTool(
|
|
6207
6599
|
"rotate",
|
|
6208
6600
|
{
|
|
6601
|
+
title: "Rotate site credential",
|
|
6209
6602
|
description: "Optionally rotate this site management credential. The first call is a read-only preview. Only confirmed:true after explicit user approval installs a locally generated new credential and revokes every previous credential. Rotation is never required to deploy.",
|
|
6210
6603
|
inputSchema: z5.object({
|
|
6211
6604
|
confirmed: z5.boolean().optional().describe(
|
|
@@ -6215,7 +6608,7 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6215
6608
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
6216
6609
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
|
|
6217
6610
|
},
|
|
6218
|
-
async (args, call) => {
|
|
6611
|
+
withDecisionReentry("rotate", async (args, call) => {
|
|
6219
6612
|
let releaseLock;
|
|
6220
6613
|
try {
|
|
6221
6614
|
const ctx = await withProjectDir(baseCtx, call);
|
|
@@ -6237,7 +6630,19 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6237
6630
|
schemaVersion: 1,
|
|
6238
6631
|
outcome: "completed",
|
|
6239
6632
|
resultCode: "credential_rotation_resumed",
|
|
6240
|
-
summary:
|
|
6633
|
+
summary: summaryMarkdown({
|
|
6634
|
+
title: `Credential rotation resumed and completed for ${site.url ?? site.siteId}`,
|
|
6635
|
+
facts: [
|
|
6636
|
+
["Site", site.url ?? site.siteId],
|
|
6637
|
+
["New credential stored at", ".sakupa/site.json (this project only)"],
|
|
6638
|
+
["Previous credentials", "all revoked"]
|
|
6639
|
+
],
|
|
6640
|
+
notes: [
|
|
6641
|
+
"Every previous credential is revoked; old project folders and backup copies can no longer manage this site.",
|
|
6642
|
+
"No credential value is shown."
|
|
6643
|
+
],
|
|
6644
|
+
next: ["`status`"]
|
|
6645
|
+
}),
|
|
6241
6646
|
data: {
|
|
6242
6647
|
siteId: site.siteId,
|
|
6243
6648
|
credentialCreatedAt: resumed.status.credentialCreatedAt,
|
|
@@ -6252,9 +6657,20 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6252
6657
|
const status = await ctx.client.getCredentialStatus(site.siteId, site.credential);
|
|
6253
6658
|
const confirmation = { confirmed: true };
|
|
6254
6659
|
if (args.confirmed !== true) {
|
|
6255
|
-
return
|
|
6660
|
+
return presentDecision(baseCtx.decisions, call, "rotate", {
|
|
6256
6661
|
resultCode: "credential_rotation_confirmation_required",
|
|
6257
|
-
summary:
|
|
6662
|
+
summary: summaryMarkdown({
|
|
6663
|
+
title: `Rotate the management credential for ${site.url ?? site.siteId}? Nothing was changed.`,
|
|
6664
|
+
facts: [
|
|
6665
|
+
["Current credential created at", timestampForAgent(status.credentialCreatedAt)],
|
|
6666
|
+
["Rotation", "optional; deploy remains available"],
|
|
6667
|
+
["Exact confirm arguments", JSON.stringify(confirmation)]
|
|
6668
|
+
],
|
|
6669
|
+
notes: [
|
|
6670
|
+
"Rotating generates a new credential locally, saves it as the current credential in this project .sakupa/site.json, and revokes EVERY previous credential for this site\u2014including copies in old folders and backups.",
|
|
6671
|
+
"Ask the user for explicit approval; never expose credential values."
|
|
6672
|
+
]
|
|
6673
|
+
}),
|
|
6258
6674
|
data: {
|
|
6259
6675
|
siteId: site.siteId,
|
|
6260
6676
|
credentialCreatedAt: status.credentialCreatedAt,
|
|
@@ -6305,7 +6721,21 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6305
6721
|
schemaVersion: 1,
|
|
6306
6722
|
outcome: "completed",
|
|
6307
6723
|
resultCode: "credential_rotated",
|
|
6308
|
-
summary:
|
|
6724
|
+
summary: summaryMarkdown({
|
|
6725
|
+
title: `Management credential rotated for ${site.url ?? site.siteId}`,
|
|
6726
|
+
facts: [
|
|
6727
|
+
["New credential stored at", ".sakupa/site.json (this project only)"],
|
|
6728
|
+
[
|
|
6729
|
+
"Previous credentials revoked",
|
|
6730
|
+
completed.rotation?.revokedPreviousCredentials ?? "all"
|
|
6731
|
+
]
|
|
6732
|
+
],
|
|
6733
|
+
notes: [
|
|
6734
|
+
"Every previous credential is revoked; old project folders and backup copies can no longer manage this site.",
|
|
6735
|
+
"No credential value is shown."
|
|
6736
|
+
],
|
|
6737
|
+
next: ["`status`"]
|
|
6738
|
+
}),
|
|
6309
6739
|
data: {
|
|
6310
6740
|
siteId: site.siteId,
|
|
6311
6741
|
credentialCreatedAt: completed.status.credentialCreatedAt,
|
|
@@ -6322,7 +6752,7 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
6322
6752
|
} finally {
|
|
6323
6753
|
releaseLock?.();
|
|
6324
6754
|
}
|
|
6325
|
-
}
|
|
6755
|
+
})
|
|
6326
6756
|
);
|
|
6327
6757
|
}
|
|
6328
6758
|
|
|
@@ -6374,11 +6804,13 @@ language. Keep option IDs, tool names, exact arguments, URLs, field names and co
|
|
|
6374
6804
|
unchanged.
|
|
6375
6805
|
|
|
6376
6806
|
Project directory contract: before the first deploy or a new recovery, CALL the init MCP tool with
|
|
6377
|
-
NO path argument. init uses the IDE's exact MCP Root
|
|
6807
|
+
NO path argument. init uses the IDE's exact MCP Root \u2014 or, when the client provides no Roots, the
|
|
6808
|
+
SAKUPA_PROJECT_ROOT directory configured for this MCP server \u2014 and creates the non-secret
|
|
6378
6809
|
.sakupa/project.json directly there. Do not merely print installation or CLI instructions when the
|
|
6379
|
-
init tool is available. Only after help confirms that the client
|
|
6380
|
-
AI itself use the CLI command "npx -y @sakupa/mcp@latest init" as a
|
|
6381
|
-
run it. The CLI also accepts NO path argument. ONE MCP process = ONE
|
|
6810
|
+
init tool is available. Only after help confirms that the client provides neither MCP Roots nor
|
|
6811
|
+
SAKUPA_PROJECT_ROOT may the AI itself use the CLI command "npx -y @sakupa/mcp@latest init" as a
|
|
6812
|
+
fallback; never ask the user to run it. The CLI also accepts NO path argument. ONE MCP process = ONE
|
|
6813
|
+
locked project = ONE site.
|
|
6382
6814
|
Site tools do not accept projectDir and cannot select another root; help, plans and report preview
|
|
6383
6815
|
and public_recovery portal remain project-independent.
|
|
6384
6816
|
Sakupa stores .sakupa/site.json and recovery state only in the locked directory; it never uses
|
|
@@ -6449,13 +6881,39 @@ Safety boundaries:
|
|
|
6449
6881
|
a bound custom domain, a lost credential is unrecoverable by design. portal then opens
|
|
6450
6882
|
Stripe's public no-code portal login, where the customer verifies the checkout email with a
|
|
6451
6883
|
Stripe one-time passcode; it never restores site authority.`;
|
|
6884
|
+
var DECISION_ROUND_TIMEOUT_MS = 12e4;
|
|
6885
|
+
var DECISION_STATE_TTL_SECONDS = 900;
|
|
6886
|
+
function clientSupportsFormElicitation(server, call) {
|
|
6887
|
+
let declared;
|
|
6888
|
+
if (call?.mcpReq.envelope !== void 0) {
|
|
6889
|
+
const envelope = call.mcpReq.envelope;
|
|
6890
|
+
declared = envelope[CLIENT_CAPABILITIES_META_KEY];
|
|
6891
|
+
} else {
|
|
6892
|
+
declared = server.server.getClientCapabilities();
|
|
6893
|
+
}
|
|
6894
|
+
const elicitation = declared?.elicitation;
|
|
6895
|
+
if (!elicitation || typeof elicitation !== "object") return false;
|
|
6896
|
+
if (elicitation.form !== void 0) return true;
|
|
6897
|
+
return elicitation.url === void 0;
|
|
6898
|
+
}
|
|
6452
6899
|
function createSakupaMcpServer(opts) {
|
|
6453
6900
|
const client = opts.client ?? new HttpApiClient(
|
|
6454
6901
|
new FetchTransport(opts.apiBaseUrl, { testAccessToken: opts.testAccessToken })
|
|
6455
6902
|
);
|
|
6903
|
+
const decisionCodec = createRequestStateCodec({
|
|
6904
|
+
key: randomBytes2(32),
|
|
6905
|
+
ttlSeconds: DECISION_STATE_TTL_SECONDS
|
|
6906
|
+
});
|
|
6456
6907
|
const server = new McpServer(
|
|
6457
6908
|
{ name: "sakupa", version: MCP_VERSION },
|
|
6458
|
-
{
|
|
6909
|
+
{
|
|
6910
|
+
instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)),
|
|
6911
|
+
// A native decision prompt must resolve well inside common IDE tool
|
|
6912
|
+
// deadlines; past this the legacy shim fails the round and the tool
|
|
6913
|
+
// falls back to the text decision on the next call.
|
|
6914
|
+
inputRequired: { roundTimeoutMs: DECISION_ROUND_TIMEOUT_MS },
|
|
6915
|
+
requestState: { verify: (state, call) => decisionCodec.verify(state, call) }
|
|
6916
|
+
}
|
|
6459
6917
|
);
|
|
6460
6918
|
const processCwd = resolve6(opts.projectDir ?? process.cwd());
|
|
6461
6919
|
const rootsProvider = opts.rootsProvider ?? ((call) => readClientRoots(server, call));
|
|
@@ -6464,7 +6922,17 @@ function createSakupaMcpServer(opts) {
|
|
|
6464
6922
|
apiBaseUrl: opts.apiBaseUrl,
|
|
6465
6923
|
projectDir: processCwd,
|
|
6466
6924
|
rootsProvider,
|
|
6467
|
-
|
|
6925
|
+
...opts.projectRoot !== void 0 ? { configuredProjectRoot: opts.projectRoot } : {},
|
|
6926
|
+
projectBinding: new ProjectBindingResolver(
|
|
6927
|
+
processCwd,
|
|
6928
|
+
rootsProvider,
|
|
6929
|
+
MCP_ROOTS_TIMEOUT_MS,
|
|
6930
|
+
opts.projectRoot
|
|
6931
|
+
),
|
|
6932
|
+
decisions: {
|
|
6933
|
+
supportsFormElicitation: (call) => clientSupportsFormElicitation(server, call),
|
|
6934
|
+
codec: decisionCodec
|
|
6935
|
+
}
|
|
6468
6936
|
};
|
|
6469
6937
|
registerTools(server, ctx);
|
|
6470
6938
|
registerBillingTools(server, ctx);
|