@sakupa/mcp 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/bin.js +253 -163
  2. package/dist/index.js +248 -163
  3. package/package.json +15 -3
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.0.0";
150
+ var SAKUPA_MCP_VERSION = "1.2.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";
@@ -1536,9 +1538,14 @@ async function analyzeProject(projectDir, opts = {}) {
1536
1538
  };
1537
1539
  }
1538
1540
 
1541
+ // src/tools/context.ts
1542
+ import {
1543
+ inputRequired
1544
+ } from "@modelcontextprotocol/server";
1545
+
1539
1546
  // src/project-binding.ts
1540
1547
  import { fileURLToPath } from "node:url";
1541
- import { resolve as resolve3 } from "node:path";
1548
+ import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path";
1542
1549
 
1543
1550
  // src/project-root.ts
1544
1551
  import { randomUUID as randomUUID2 } from "node:crypto";
@@ -1763,6 +1770,12 @@ function writeMarkerAtomically(projectDir, marker) {
1763
1770
 
1764
1771
  // src/project-binding.ts
1765
1772
  var MCP_ROOTS_TIMEOUT_MS = 5e3;
1773
+ var McpRootsPending = class extends Error {
1774
+ constructor() {
1775
+ super("MCP Roots must be requested from the client before the project can be bound.");
1776
+ this.name = "McpRootsPending";
1777
+ }
1778
+ };
1766
1779
  var ProjectBindingError = class extends Error {
1767
1780
  diagnostics;
1768
1781
  constructor(diagnostics) {
@@ -1772,18 +1785,19 @@ var ProjectBindingError = class extends Error {
1772
1785
  }
1773
1786
  };
1774
1787
  var ProjectBindingResolver = class {
1775
- constructor(processCwd, rootsProvider, rootsTimeoutMs = MCP_ROOTS_TIMEOUT_MS) {
1788
+ constructor(processCwd, rootsProvider, rootsTimeoutMs = MCP_ROOTS_TIMEOUT_MS, configuredRoot) {
1776
1789
  this.processCwd = processCwd;
1777
1790
  this.rootsProvider = rootsProvider;
1778
1791
  this.rootsTimeoutMs = rootsTimeoutMs;
1792
+ this.configuredRoot = configuredRoot;
1779
1793
  }
1780
1794
  bound;
1781
1795
  boundState;
1782
1796
  resolving;
1783
- async resolve() {
1797
+ async resolve(call) {
1784
1798
  if (this.bound) return this.bound;
1785
1799
  if (this.resolving) return this.resolving;
1786
- this.resolving = this.inspect().then((inspection) => {
1800
+ this.resolving = this.inspect(false, call).then((inspection) => {
1787
1801
  if (!inspection.selected) throw new ProjectBindingError(inspection.diagnostics);
1788
1802
  this.bound = inspection.selected;
1789
1803
  this.boundState = inspection.diagnostics;
@@ -1793,18 +1807,18 @@ var ProjectBindingResolver = class {
1793
1807
  });
1794
1808
  return this.resolving;
1795
1809
  }
1796
- async diagnose() {
1810
+ async diagnose(call) {
1797
1811
  if (this.bound) return this.boundState ?? boundDiagnostics(this.processCwd, this.bound);
1798
- const inspection = await this.inspect();
1812
+ const inspection = await this.inspect(false, call);
1799
1813
  if (inspection.selected) {
1800
1814
  this.bound = inspection.selected;
1801
1815
  this.boundState = inspection.diagnostics;
1802
1816
  }
1803
1817
  return inspection.diagnostics;
1804
1818
  }
1805
- async initialize() {
1819
+ async initialize(call) {
1806
1820
  if (this.bound) return this.bound;
1807
- const inspection = await this.inspect(true);
1821
+ const inspection = await this.inspect(true, call);
1808
1822
  if (inspection.selected) {
1809
1823
  this.bound = inspection.selected;
1810
1824
  this.boundState = inspection.diagnostics;
@@ -1814,28 +1828,41 @@ var ProjectBindingResolver = class {
1814
1828
  throw new ProjectBindingError(inspection.diagnostics);
1815
1829
  }
1816
1830
  const initialized = initializeProject(inspection.initializableRoot);
1817
- this.bound = { ...initialized, bindingSource: "mcp_root" };
1831
+ this.bound = {
1832
+ ...initialized,
1833
+ bindingSource: inspection.initializableSource ?? "mcp_root"
1834
+ };
1818
1835
  this.boundState = boundDiagnostics(
1819
1836
  this.processCwd,
1820
1837
  this.bound,
1821
- { supported: true, roots: [] },
1822
- inspection.diagnostics.rootCandidates
1838
+ { supported: inspection.diagnostics.mcpRootsSupported, roots: [] },
1839
+ inspection.diagnostics.rootCandidates,
1840
+ inspection.diagnostics.configuredProjectRoot
1823
1841
  );
1824
1842
  return this.bound;
1825
1843
  }
1826
- async inspect(forInitialization = false) {
1827
- const snapshot = await safeRootsSnapshot(this.rootsProvider, this.rootsTimeoutMs);
1844
+ async inspect(forInitialization = false, call) {
1845
+ const snapshot = await safeRootsSnapshot(this.rootsProvider, this.rootsTimeoutMs, call);
1828
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
+ });
1829
1859
  const initializedRoots = rootCandidates.filter(
1830
1860
  (candidate) => candidate.initialized && candidate.path !== void 0
1831
1861
  );
1832
1862
  if (snapshot.supported && snapshot.error) {
1833
1863
  return {
1834
- diagnostics: diagnostic(
1864
+ diagnostics: diagnose(
1835
1865
  "roots_request_failed",
1836
- snapshot,
1837
- this.processCwd,
1838
- rootCandidates,
1839
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."
1840
1867
  )
1841
1868
  };
@@ -1844,19 +1871,12 @@ var ProjectBindingResolver = class {
1844
1871
  const initializedRoot = initializedRoots[0];
1845
1872
  if (!initializedRoot) throw new Error("initialized Root disappeared during resolution");
1846
1873
  const project = resolveLockedProjectRoot(initializedRoot.path);
1847
- const selected = { ...project, bindingSource: "mcp_root" };
1848
- return {
1849
- selected,
1850
- diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
1851
- };
1874
+ return bound({ ...project, bindingSource: "mcp_root" });
1852
1875
  }
1853
1876
  if (initializedRoots.length > 1) {
1854
1877
  return {
1855
- diagnostics: diagnostic(
1878
+ diagnostics: diagnose(
1856
1879
  "multiple_initialized_roots",
1857
- snapshot,
1858
- this.processCwd,
1859
- rootCandidates,
1860
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."
1861
1881
  )
1862
1882
  };
@@ -1870,22 +1890,17 @@ var ProjectBindingResolver = class {
1870
1890
  if (!validRoot) throw new Error("workspace Root disappeared during initialization");
1871
1891
  return {
1872
1892
  initializableRoot: validRoot.path,
1873
- diagnostics: diagnostic(
1893
+ initializableSource: "mcp_root",
1894
+ diagnostics: diagnose(
1874
1895
  "workspace_not_initialized",
1875
- snapshot,
1876
- this.processCwd,
1877
- rootCandidates,
1878
1896
  `The active MCP workspace ${validRoot.path} is ready to initialize.`
1879
1897
  )
1880
1898
  };
1881
1899
  }
1882
1900
  if (validRoots.length > 1) {
1883
1901
  return {
1884
- diagnostics: diagnostic(
1902
+ diagnostics: diagnose(
1885
1903
  "multiple_uninitialized_roots",
1886
- snapshot,
1887
- this.processCwd,
1888
- rootCandidates,
1889
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."
1890
1905
  )
1891
1906
  };
@@ -1895,54 +1910,59 @@ var ProjectBindingResolver = class {
1895
1910
  const validRoot = validRoots[0];
1896
1911
  if (!validRoot) throw new Error("workspace Root disappeared during diagnosis");
1897
1912
  return {
1898
- diagnostics: diagnostic(
1913
+ diagnostics: diagnose(
1899
1914
  "workspace_not_initialized",
1900
- snapshot,
1901
- this.processCwd,
1902
- rootCandidates,
1903
1915
  `The IDE workspace ${validRoot.path} is not initialized. Call init with no path arguments; it will create .sakupa directly in that workspace Root.`
1904
1916
  )
1905
1917
  };
1906
1918
  }
1907
1919
  if (snapshot.supported && validRoots.length > 1) {
1908
1920
  return {
1909
- diagnostics: diagnostic(
1921
+ diagnostics: diagnose(
1910
1922
  "multiple_uninitialized_roots",
1911
- snapshot,
1912
- this.processCwd,
1913
- rootCandidates,
1914
1923
  "The IDE exposes multiple uninitialized workspace Roots. Open only the intended project, then call init. Sakupa will not guess a project directory."
1915
1924
  )
1916
1925
  };
1917
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
+ }
1918
1948
  if (snapshot.supported) {
1919
1949
  return {
1920
- diagnostics: diagnostic(
1950
+ diagnostics: diagnose(
1921
1951
  "workspace_not_initialized",
1922
- snapshot,
1923
- this.processCwd,
1924
- rootCandidates,
1925
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."
1926
1953
  )
1927
1954
  };
1928
1955
  }
1929
1956
  try {
1930
1957
  const cwdProject = resolveLockedProjectRoot(this.processCwd);
1931
- const selected = { ...cwdProject, bindingSource: "process_cwd" };
1932
- return {
1933
- selected,
1934
- diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
1935
- };
1958
+ return bound({ ...cwdProject, bindingSource: "process_cwd" });
1936
1959
  } catch {
1937
1960
  }
1938
1961
  const cwdProblem = inspectDirectory(this.processCwd);
1939
1962
  return {
1940
- diagnostics: diagnostic(
1963
+ diagnostics: diagnose(
1941
1964
  cwdProblem.problem ? "invalid_process_cwd" : "process_cwd_is_not_workspace",
1942
- snapshot,
1943
- this.processCwd,
1944
- rootCandidates,
1945
- "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."
1946
1966
  )
1947
1967
  };
1948
1968
  }
@@ -1952,11 +1972,12 @@ function fileRootUriToPath(uri, windows = process.platform === "win32") {
1952
1972
  if (parsed.protocol !== "file:") throw new Error("Root URI is not a file URI");
1953
1973
  return fileURLToPath(parsed, { windows });
1954
1974
  }
1955
- async function safeRootsSnapshot(provider, timeoutMs = MCP_ROOTS_TIMEOUT_MS) {
1975
+ async function safeRootsSnapshot(provider, timeoutMs = MCP_ROOTS_TIMEOUT_MS, call) {
1956
1976
  if (!provider) return { supported: false, roots: [] };
1957
1977
  try {
1958
- return await withOperationTimeout("MCP Roots request", timeoutMs, () => provider());
1978
+ return await withOperationTimeout("MCP Roots request", timeoutMs, () => provider(call));
1959
1979
  } catch (error) {
1980
+ if (error instanceof McpRootsPending) throw error;
1960
1981
  return {
1961
1982
  supported: true,
1962
1983
  roots: [],
@@ -1984,6 +2005,31 @@ function inspectRoot(root) {
1984
2005
  };
1985
2006
  }
1986
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
+ }
1987
2033
  function inspectDirectory(path) {
1988
2034
  try {
1989
2035
  return { path: canonicalProjectDirectory(resolve3(path)) };
@@ -1991,22 +2037,24 @@ function inspectDirectory(path) {
1991
2037
  return { problem: error instanceof Error ? error.message : String(error) };
1992
2038
  }
1993
2039
  }
1994
- function diagnostic(diagnosisCode, snapshot, processCwd, rootCandidates, guidance) {
2040
+ function diagnostic(diagnosisCode, snapshot, processCwd, rootCandidates, guidance, configured) {
1995
2041
  return {
1996
2042
  diagnosisCode,
1997
2043
  mcpRootsSupported: snapshot.supported,
1998
2044
  processCwd,
1999
2045
  rootCandidates,
2046
+ ...configured ? { configuredProjectRoot: configured } : {},
2000
2047
  guidance,
2001
2048
  reportRecommended: false
2002
2049
  };
2003
2050
  }
2004
- function boundDiagnostics(processCwd, selected, snapshot = { supported: false, roots: [] }, rootCandidates = []) {
2051
+ function boundDiagnostics(processCwd, selected, snapshot = { supported: false, roots: [] }, rootCandidates = [], configured) {
2005
2052
  return {
2006
2053
  diagnosisCode: "project_bound",
2007
2054
  mcpRootsSupported: snapshot.supported,
2008
2055
  processCwd,
2009
2056
  rootCandidates,
2057
+ ...configured ? { configuredProjectRoot: configured } : {},
2010
2058
  selectedProjectDir: selected.projectDir,
2011
2059
  bindingSource: selected.bindingSource,
2012
2060
  guidance: `Sakupa is locked to ${selected.projectDir} from ${selected.bindingSource}.`,
@@ -2034,7 +2082,7 @@ var TARGET_MCP_TOOL_NAMES = [
2034
2082
  "support",
2035
2083
  "report"
2036
2084
  ];
2037
- var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
2085
+ var STRUCTURED_TOOL_OUTPUT_SCHEMA = z.object({
2038
2086
  schemaVersion: z.literal(1),
2039
2087
  outcome: z.enum([
2040
2088
  "completed",
@@ -2116,7 +2164,7 @@ var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
2116
2164
  reasonCode: z.string().optional()
2117
2165
  })
2118
2166
  )
2119
- };
2167
+ });
2120
2168
  function structuredToolResult(envelope) {
2121
2169
  const clientTimeZone = clientRuntimeTimeZone();
2122
2170
  const presentation = {
@@ -2225,14 +2273,19 @@ function resolverFor(ctx) {
2225
2273
  if (ctx.projectBinding) return ctx.projectBinding;
2226
2274
  let resolver = fallbackResolvers.get(ctx);
2227
2275
  if (!resolver) {
2228
- resolver = new ProjectBindingResolver(ctx.projectDir, ctx.rootsProvider);
2276
+ resolver = new ProjectBindingResolver(
2277
+ ctx.projectDir,
2278
+ ctx.rootsProvider,
2279
+ MCP_ROOTS_TIMEOUT_MS,
2280
+ ctx.configuredProjectRoot
2281
+ );
2229
2282
  fallbackResolvers.set(ctx, resolver);
2230
2283
  }
2231
2284
  return resolver;
2232
2285
  }
2233
- async function withProjectDir(ctx) {
2286
+ async function withProjectDir(ctx, call) {
2234
2287
  try {
2235
- const binding = await resolverFor(ctx).resolve();
2288
+ const binding = await resolverFor(ctx).resolve(call);
2236
2289
  const resolved = resolveLockedProjectRoot(binding.projectDir);
2237
2290
  return {
2238
2291
  ...ctx,
@@ -2255,12 +2308,12 @@ async function withProjectDir(ctx) {
2255
2308
  throw error;
2256
2309
  }
2257
2310
  }
2258
- async function diagnoseProjectBinding(ctx) {
2259
- return resolverFor(ctx).diagnose();
2311
+ async function diagnoseProjectBinding(ctx, call) {
2312
+ return resolverFor(ctx).diagnose(call);
2260
2313
  }
2261
- async function initializeWorkspaceProject(ctx) {
2314
+ async function initializeWorkspaceProject(ctx, call) {
2262
2315
  try {
2263
- const resolved = await resolverFor(ctx).initialize();
2316
+ const resolved = await resolverFor(ctx).initialize(call);
2264
2317
  return {
2265
2318
  ...ctx,
2266
2319
  projectDir: resolved.projectDir,
@@ -2276,10 +2329,11 @@ async function initializeWorkspaceProject(ctx) {
2276
2329
  throw error;
2277
2330
  }
2278
2331
  }
2279
- async function optionalProjectContext(ctx) {
2332
+ async function optionalProjectContext(ctx, call) {
2280
2333
  try {
2281
- return await withProjectDir(ctx);
2282
- } catch {
2334
+ return await withProjectDir(ctx, call);
2335
+ } catch (error) {
2336
+ if (error instanceof McpRootsPending) throw error;
2283
2337
  return null;
2284
2338
  }
2285
2339
  }
@@ -2327,6 +2381,9 @@ function requireSiteFile(ctx) {
2327
2381
  }
2328
2382
  var UNAUTHORIZED_SUMMARY = "The server rejected the site credential: the one in .sakupa/site.json no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site.";
2329
2383
  function toolError(e) {
2384
+ if (e instanceof McpRootsPending) {
2385
+ return inputRequired({ inputRequests: { roots: inputRequired.listRoots() } });
2386
+ }
2330
2387
  const isSakupa = isSakupaError(e);
2331
2388
  const errorCode = isSakupa ? e.code : "internal";
2332
2389
  const rawDetails = isSakupaError(e) && e.details && typeof e.details === "object" ? e.details : void 0;
@@ -2398,7 +2455,7 @@ import { z as z2 } from "zod";
2398
2455
  // src/recovery-archive.ts
2399
2456
  import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
2400
2457
  import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2401
- import { dirname as dirname2, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
2458
+ import { dirname as dirname2, isAbsolute as isAbsolute3, join as join4, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
2402
2459
 
2403
2460
  // ../../node_modules/fflate/esm/index.mjs
2404
2461
  import { createRequire } from "module";
@@ -2885,13 +2942,13 @@ function unzipSync(data, opts) {
2885
2942
 
2886
2943
  // src/recovery-archive.ts
2887
2944
  function safeOutputPath(projectDir, outputDir) {
2888
- if (outputDir.length === 0 || isAbsolute2(outputDir)) {
2945
+ if (outputDir.length === 0 || isAbsolute3(outputDir)) {
2889
2946
  throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
2890
2947
  }
2891
2948
  const root = realpathSync2(resolve4(projectDir));
2892
2949
  const target = resolve4(root, outputDir);
2893
2950
  const rel = relative2(root, target);
2894
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute2(rel)) {
2951
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute3(rel)) {
2895
2952
  throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
2896
2953
  }
2897
2954
  if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep3}`)) {
@@ -2906,7 +2963,7 @@ function safeOutputPath(projectDir, outputDir) {
2906
2963
  const physicalAncestor = realpathSync2(existingAncestor);
2907
2964
  const physicalTarget = resolve4(physicalAncestor, relative2(existingAncestor, target));
2908
2965
  const physicalRel = relative2(root, physicalTarget);
2909
- if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) || isAbsolute2(physicalRel)) {
2966
+ if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) || isAbsolute3(physicalRel)) {
2910
2967
  throw new SakupaError(
2911
2968
  "invalid_request",
2912
2969
  "Recovery outputDir resolves through a symlink outside projectDir"
@@ -3272,7 +3329,7 @@ import {
3272
3329
  writeFileSync as writeFileSync5
3273
3330
  } from "node:fs";
3274
3331
  import { createHash } from "node:crypto";
3275
- import { dirname as dirname5, isAbsolute as isAbsolute3, join as join7 } from "node:path";
3332
+ import { dirname as dirname5, isAbsolute as isAbsolute4, join as join7 } from "node:path";
3276
3333
  var HANDOFF_LOCK_TTL_MS = 15 * 60 * 1e3;
3277
3334
  function normalizeSiteUrl(raw) {
3278
3335
  const url = new URL(raw);
@@ -3299,7 +3356,7 @@ function resolveReusableSite(rawUrl, currentProjectDir, nowMs, apiBaseUrl) {
3299
3356
  throw new Error(`More than one local free-site record matches ${siteUrl}.`);
3300
3357
  const record = matches2[0];
3301
3358
  if (!record) throw new Error("The selected existing free site disappeared during resolution.");
3302
- if (!isAbsolute3(record.projectDir)) {
3359
+ if (!isAbsolute4(record.projectDir)) {
3303
3360
  throw new Error("The selected free-site project path is not absolute; refusing cwd lookup.");
3304
3361
  }
3305
3362
  const sourceProjectDir = canonicalProjectDirectory(record.projectDir);
@@ -4146,13 +4203,13 @@ function registerTools(server, baseCtx) {
4146
4203
  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.",
4147
4204
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4148
4205
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
4149
- inputSchema: {
4206
+ inputSchema: z2.object({
4150
4207
  outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection).")
4151
- }
4208
+ })
4152
4209
  },
4153
- async (args) => {
4210
+ async (args, call) => {
4154
4211
  try {
4155
- const ctx = await withProjectDir(baseCtx);
4212
+ const ctx = await withProjectDir(baseCtx, call);
4156
4213
  const analysis = await analyzeProject(ctx.projectDir, {
4157
4214
  ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
4158
4215
  });
@@ -4173,7 +4230,7 @@ Next action: ${analysis.suggestedNextAction}`,
4173
4230
  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.`,
4174
4231
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4175
4232
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
4176
- inputSchema: {
4233
+ inputSchema: z2.object({
4177
4234
  outputDir: z2.string().min(1).describe(
4178
4235
  'REQUIRED: exact publish directory relative to the initialized project root, supplied by the AI after inspecting this project (for example ".", "dist", "html", or any custom build directory). Sakupa applies it only inside the cwd-locked project.'
4179
4236
  ),
@@ -4199,12 +4256,12 @@ Next action: ${analysis.suggestedNextAction}`,
4199
4256
  "Deprecated compatibility field. Project independence is established only by `sakupa-mcp init`, never inferred from package.json or folder names."
4200
4257
  ),
4201
4258
  lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
4202
- }
4259
+ })
4203
4260
  },
4204
- async (args) => {
4261
+ async (args, call) => {
4205
4262
  let releaseHandoffLock;
4206
4263
  try {
4207
- const ctx = await withProjectDir(baseCtx);
4264
+ const ctx = await withProjectDir(baseCtx, call);
4208
4265
  const analysis = await analyzeProject(ctx.projectDir, { outputDir: args.outputDir });
4209
4266
  if (!analysis.deployable || !analysis.files) {
4210
4267
  return notDeployableResult(analysis);
@@ -4829,11 +4886,11 @@ Optional security recommendation: this management credential was created at ${ti
4829
4886
  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.",
4830
4887
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4831
4888
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
4832
- inputSchema: {}
4889
+ inputSchema: z2.object({})
4833
4890
  },
4834
- async () => {
4891
+ async (_args, call) => {
4835
4892
  try {
4836
- const ctx = await withProjectDir(baseCtx);
4893
+ const ctx = await withProjectDir(baseCtx, call);
4837
4894
  const site = requireSiteFile(ctx);
4838
4895
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
4839
4896
  if (site.url) {
@@ -4862,11 +4919,11 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4862
4919
  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.",
4863
4920
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4864
4921
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
4865
- inputSchema: {}
4922
+ inputSchema: z2.object({})
4866
4923
  },
4867
- async () => {
4924
+ async (_args, call) => {
4868
4925
  try {
4869
- const ctx = await withProjectDir(baseCtx);
4926
+ const ctx = await withProjectDir(baseCtx, call);
4870
4927
  const site = requireSiteFile(ctx);
4871
4928
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
4872
4929
  noteSiteMode(res.siteId, res.mode);
@@ -4893,15 +4950,15 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4893
4950
  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.`,
4894
4951
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4895
4952
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
4896
- inputSchema: {
4953
+ inputSchema: z2.object({
4897
4954
  plan: planEnum.describe(
4898
4955
  "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
4899
4956
  )
4900
- }
4957
+ })
4901
4958
  },
4902
- async (args) => {
4959
+ async (args, call) => {
4903
4960
  try {
4904
- const ctx = await withProjectDir(baseCtx);
4961
+ const ctx = await withProjectDir(baseCtx, call);
4905
4962
  const site = requireSiteFile(ctx);
4906
4963
  const res = await ctx.client.createPlanCheckout(
4907
4964
  {
@@ -4939,17 +4996,17 @@ Once Stripe confirms payment and Sakupa synchronizes the subscription, the curre
4939
4996
  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.`,
4940
4997
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4941
4998
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
4942
- inputSchema: {
4999
+ inputSchema: z2.object({
4943
5000
  action: z2.enum(["start", "status"]),
4944
5001
  hostname: z2.string().optional().describe("Required for start."),
4945
5002
  verificationId: z2.string().optional().describe(
4946
5003
  "Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
4947
5004
  )
4948
- }
5005
+ })
4949
5006
  },
4950
- async (args) => {
5007
+ async (args, call) => {
4951
5008
  try {
4952
- const ctx = await withProjectDir(baseCtx);
5009
+ const ctx = await withProjectDir(baseCtx, call);
4953
5010
  const site = requireSiteFile(ctx);
4954
5011
  if (args.action === "status") {
4955
5012
  const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
@@ -5078,11 +5135,11 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
5078
5135
  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).",
5079
5136
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5080
5137
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
5081
- inputSchema: {}
5138
+ inputSchema: z2.object({})
5082
5139
  },
5083
- async () => {
5140
+ async (_args, call) => {
5084
5141
  try {
5085
- const ctx = await withProjectDir(baseCtx);
5142
+ const ctx = await withProjectDir(baseCtx, call);
5086
5143
  const site = requireSiteFile(ctx);
5087
5144
  const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
5088
5145
  noteSiteMode(res.siteId, res.mode);
@@ -5122,14 +5179,14 @@ Full status:`, res);
5122
5179
  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.",
5123
5180
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5124
5181
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
5125
- inputSchema: {
5182
+ inputSchema: z2.object({
5126
5183
  scope: z2.enum(["site", "public_recovery"])
5127
- }
5184
+ })
5128
5185
  },
5129
- async (args) => {
5186
+ async (args, call) => {
5130
5187
  try {
5131
5188
  if (args.scope === "site") {
5132
- const ctx = await withProjectDir(baseCtx);
5189
+ const ctx = await withProjectDir(baseCtx, call);
5133
5190
  const site = requireSiteFile(ctx);
5134
5191
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
5135
5192
  return structuredToolResult({
@@ -5178,7 +5235,7 @@ Full status:`, res);
5178
5235
  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.",
5179
5236
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5180
5237
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
5181
- inputSchema: {
5238
+ inputSchema: z2.object({
5182
5239
  action: z2.enum(["start", "status", "complete", "download"]),
5183
5240
  hostname: z2.string().optional().describe("Required for start."),
5184
5241
  verificationId: z2.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
@@ -5186,11 +5243,11 @@ Full status:`, res);
5186
5243
  "REQUIRED for complete/download: exact extraction directory relative to the initialized project root. Inspect the current project; Sakupa never guesses a name."
5187
5244
  ),
5188
5245
  preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
5189
- }
5246
+ })
5190
5247
  },
5191
- async (args) => {
5248
+ async (args, call) => {
5192
5249
  try {
5193
- const ctx = await withProjectDir(baseCtx);
5250
+ const ctx = await withProjectDir(baseCtx, call);
5194
5251
  if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
5195
5252
  throw new LocalGuidanceError(
5196
5253
  "invalid_request",
@@ -5539,16 +5596,16 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5539
5596
  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.",
5540
5597
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5541
5598
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
5542
- inputSchema: {
5599
+ inputSchema: z2.object({
5543
5600
  category: ticketCategoryEnum,
5544
5601
  subject: z2.string().describe("Short subject line."),
5545
5602
  description: z2.string().describe("Problem description (no secrets, no card data)."),
5546
5603
  contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
5547
- }
5604
+ })
5548
5605
  },
5549
- async (args) => {
5606
+ async (args, call) => {
5550
5607
  try {
5551
- const ctx = await withProjectDir(baseCtx);
5608
+ const ctx = await withProjectDir(baseCtx, call);
5552
5609
  const site = requireSiteFile(ctx);
5553
5610
  const res = await ctx.client.createTicket(site.credential, {
5554
5611
  siteId: site.siteId,
@@ -5573,7 +5630,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5573
5630
  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.",
5574
5631
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5575
5632
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
5576
- inputSchema: {
5633
+ inputSchema: z2.object({
5577
5634
  toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy".'),
5578
5635
  helpAuthorization: z2.string().describe("Short-lived authorization returned only by help when report is recommended."),
5579
5636
  errorCode: z2.string().optional(),
@@ -5589,12 +5646,12 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5589
5646
  "OPTIONAL. Before submitting, ask the user ONCE whether they want to leave a contact for follow-up. Omit entirely if they decline \u2014 never require it."
5590
5647
  ),
5591
5648
  confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
5592
- }
5649
+ })
5593
5650
  },
5594
- async (args) => {
5651
+ async (args, call) => {
5595
5652
  try {
5596
5653
  requireReportAuthorization(baseCtx, args.helpAuthorization, args.toolName);
5597
- const ctx = await optionalProjectContext(baseCtx);
5654
+ const ctx = await optionalProjectContext(baseCtx, call);
5598
5655
  const siteState = ctx ? loadSiteFile(ctx.projectDir) : { kind: "absent" };
5599
5656
  const site = siteState.kind === "ok" ? siteState.file : null;
5600
5657
  const diagnostics = {
@@ -5668,7 +5725,11 @@ Summary: ${res.sanitizedSummary}`,
5668
5725
  }
5669
5726
 
5670
5727
  // src/server.ts
5671
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5728
+ import {
5729
+ CLIENT_CAPABILITIES_META_KEY,
5730
+ McpServer,
5731
+ inputResponse
5732
+ } from "@modelcontextprotocol/server";
5672
5733
 
5673
5734
  // src/tools/billing.ts
5674
5735
  import { z as z3 } from "zod";
@@ -5677,11 +5738,11 @@ function registerBillingTools(server, baseCtx) {
5677
5738
  "plans",
5678
5739
  {
5679
5740
  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.",
5680
- inputSchema: {},
5741
+ inputSchema: z3.object({}),
5681
5742
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5682
5743
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
5683
5744
  },
5684
- async () => {
5745
+ async (_args, call) => {
5685
5746
  try {
5686
5747
  const catalog = await baseCtx.client.getBillingPlanCatalog();
5687
5748
  return structuredToolResult({
@@ -5701,15 +5762,15 @@ function registerBillingTools(server, baseCtx) {
5701
5762
  "change",
5702
5763
  {
5703
5764
  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.",
5704
- inputSchema: {
5765
+ inputSchema: z3.object({
5705
5766
  operationId: z3.string().min(1)
5706
- },
5767
+ }),
5707
5768
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5708
5769
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
5709
5770
  },
5710
- async (args) => {
5771
+ async (args, call) => {
5711
5772
  try {
5712
- const ctx = await withProjectDir(baseCtx);
5773
+ const ctx = await withProjectDir(baseCtx, call);
5713
5774
  const site = requireSiteFile(ctx);
5714
5775
  const result = await ctx.client.changeSubscriptionPlan(site.credential, {
5715
5776
  siteId: site.siteId,
@@ -5793,7 +5854,7 @@ var TOOL_MANUALS = {
5793
5854
  init: {
5794
5855
  purpose: "Initialize the active IDE workspace as one Sakupa project.",
5795
5856
  sideEffects: "Creates only .sakupa/project.json locally; no API call, site or charge.",
5796
- preconditions: "Exactly one usable MCP workspace Root. If Roots are unavailable, help may authorize the AI to use CLI init.",
5857
+ 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.",
5797
5858
  parameterNames: [],
5798
5859
  parameters: "No parameters and no path argument.",
5799
5860
  warnings: [
@@ -5992,13 +6053,13 @@ function registerHelpTools(server, baseCtx) {
5992
6053
  "init",
5993
6054
  {
5994
6055
  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.",
5995
- inputSchema: {},
6056
+ inputSchema: z4.object({}),
5996
6057
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5997
6058
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
5998
6059
  },
5999
- async () => {
6060
+ async (_args, call) => {
6000
6061
  try {
6001
- const ctx = await initializeWorkspaceProject(baseCtx);
6062
+ const ctx = await initializeWorkspaceProject(baseCtx, call);
6002
6063
  const marker = loadProjectMarker(ctx.projectDir);
6003
6064
  if (marker.kind !== "ok")
6004
6065
  throw new Error("init postcondition failed: project marker missing");
@@ -6032,17 +6093,17 @@ function registerHelpTools(server, baseCtx) {
6032
6093
  "help",
6033
6094
  {
6034
6095
  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.",
6035
- inputSchema: {
6096
+ inputSchema: z4.object({
6036
6097
  topic: z4.enum(HELP_TOPICS).optional().default("diagnose"),
6037
6098
  failedTool: z4.string().optional(),
6038
6099
  errorCode: z4.string().optional(),
6039
6100
  resultCode: z4.string().optional(),
6040
6101
  requestId: z4.string().optional()
6041
- },
6102
+ }),
6042
6103
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
6043
6104
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
6044
6105
  },
6045
- async (args) => {
6106
+ async (args, call) => {
6046
6107
  try {
6047
6108
  if (args.topic === "overview") {
6048
6109
  const catalog = Object.fromEntries(
@@ -6104,7 +6165,7 @@ Terminology: ${terminologyText}` : ""),
6104
6165
  nextActions: []
6105
6166
  });
6106
6167
  }
6107
- const diagnosis = await diagnoseProjectBinding(baseCtx);
6168
+ const diagnosis = await diagnoseProjectBinding(baseCtx, call);
6108
6169
  const selected = diagnosis.selectedProjectDir;
6109
6170
  const marker = selected ? loadProjectMarker(selected) : { kind: "absent" };
6110
6171
  const site = selected ? loadSiteFile(selected) : { kind: "absent" };
@@ -6183,18 +6244,18 @@ function registerCredentialTools(server, baseCtx) {
6183
6244
  "rotate",
6184
6245
  {
6185
6246
  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.",
6186
- inputSchema: {
6247
+ inputSchema: z5.object({
6187
6248
  confirmed: z5.boolean().optional().describe(
6188
6249
  "True only after showing the rotate preview and the user explicitly approves revoking every old credential."
6189
6250
  )
6190
- },
6251
+ }),
6191
6252
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
6192
6253
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
6193
6254
  },
6194
- async (args) => {
6255
+ async (args, call) => {
6195
6256
  let releaseLock;
6196
6257
  try {
6197
- const ctx = await withProjectDir(baseCtx);
6258
+ const ctx = await withProjectDir(baseCtx, call);
6198
6259
  let site = requireSiteFile(ctx);
6199
6260
  const pending = loadCredentialRotation(ctx.projectDir);
6200
6261
  if (pending.kind !== "absent" || args.confirmed === true) {
@@ -6350,11 +6411,13 @@ language. Keep option IDs, tool names, exact arguments, URLs, field names and co
6350
6411
  unchanged.
6351
6412
 
6352
6413
  Project directory contract: before the first deploy or a new recovery, CALL the init MCP tool with
6353
- NO path argument. init uses the IDE's exact MCP Root and creates the non-secret
6414
+ NO path argument. init uses the IDE's exact MCP Root \u2014 or, when the client provides no Roots, the
6415
+ SAKUPA_PROJECT_ROOT directory configured for this MCP server \u2014 and creates the non-secret
6354
6416
  .sakupa/project.json directly there. Do not merely print installation or CLI instructions when the
6355
- init tool is available. Only after help confirms that the client does not provide MCP Roots may the
6356
- AI itself use the CLI command "npx -y @sakupa/mcp@latest init" as a fallback; never ask the user to
6357
- run it. The CLI also accepts NO path argument. ONE MCP process = ONE Roots-first locked project = ONE site.
6417
+ init tool is available. Only after help confirms that the client provides neither MCP Roots nor
6418
+ SAKUPA_PROJECT_ROOT may the AI itself use the CLI command "npx -y @sakupa/mcp@latest init" as a
6419
+ fallback; never ask the user to run it. The CLI also accepts NO path argument. ONE MCP process = ONE
6420
+ locked project = ONE site.
6358
6421
  Site tools do not accept projectDir and cannot select another root; help, plans and report preview
6359
6422
  and public_recovery portal remain project-independent.
6360
6423
  Sakupa stores .sakupa/site.json and recovery state only in the locked directory; it never uses
@@ -6434,29 +6497,19 @@ function createSakupaMcpServer(opts) {
6434
6497
  { instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)) }
6435
6498
  );
6436
6499
  const processCwd = resolve6(opts.projectDir ?? process.cwd());
6437
- const rootsProvider = opts.rootsProvider ?? (async () => {
6438
- const capabilities = server.server.getClientCapabilities();
6439
- if (!capabilities?.roots) return { supported: false, roots: [] };
6440
- try {
6441
- const response = await server.server.listRoots(void 0, {
6442
- timeout: MCP_ROOTS_TIMEOUT_MS,
6443
- maxTotalTimeout: MCP_ROOTS_TIMEOUT_MS
6444
- });
6445
- return { supported: true, roots: response.roots };
6446
- } catch (error) {
6447
- return {
6448
- supported: true,
6449
- roots: [],
6450
- error: error instanceof Error ? error.message : String(error)
6451
- };
6452
- }
6453
- });
6500
+ const rootsProvider = opts.rootsProvider ?? ((call) => readClientRoots(server, call));
6454
6501
  const ctx = {
6455
6502
  client,
6456
6503
  apiBaseUrl: opts.apiBaseUrl,
6457
6504
  projectDir: processCwd,
6458
6505
  rootsProvider,
6459
- projectBinding: new ProjectBindingResolver(processCwd, rootsProvider)
6506
+ ...opts.projectRoot !== void 0 ? { configuredProjectRoot: opts.projectRoot } : {},
6507
+ projectBinding: new ProjectBindingResolver(
6508
+ processCwd,
6509
+ rootsProvider,
6510
+ MCP_ROOTS_TIMEOUT_MS,
6511
+ opts.projectRoot
6512
+ )
6460
6513
  };
6461
6514
  registerTools(server, ctx);
6462
6515
  registerBillingTools(server, ctx);
@@ -6464,6 +6517,38 @@ function createSakupaMcpServer(opts) {
6464
6517
  registerHelpTools(server, ctx);
6465
6518
  return server;
6466
6519
  }
6520
+ async function readClientRoots(server, call) {
6521
+ if (call?.mcpReq.envelope !== void 0) {
6522
+ const envelope = call.mcpReq.envelope;
6523
+ const declared = envelope[CLIENT_CAPABILITIES_META_KEY];
6524
+ if (!declared?.roots) return { supported: false, roots: [] };
6525
+ const answered = inputResponse(call.mcpReq.inputResponses, "roots");
6526
+ if (answered.kind === "roots") return { supported: true, roots: answered.roots };
6527
+ if (call.mcpReq.inputResponses !== void 0) {
6528
+ return {
6529
+ supported: true,
6530
+ roots: [],
6531
+ error: "The client retried without answering the embedded roots/list request."
6532
+ };
6533
+ }
6534
+ throw new McpRootsPending();
6535
+ }
6536
+ const capabilities = server.server.getClientCapabilities();
6537
+ if (!capabilities?.roots) return { supported: false, roots: [] };
6538
+ try {
6539
+ const response = await server.server.listRoots(void 0, {
6540
+ timeout: MCP_ROOTS_TIMEOUT_MS,
6541
+ maxTotalTimeout: MCP_ROOTS_TIMEOUT_MS
6542
+ });
6543
+ return { supported: true, roots: response.roots };
6544
+ } catch (error) {
6545
+ return {
6546
+ supported: true,
6547
+ roots: [],
6548
+ error: error instanceof Error ? error.message : String(error)
6549
+ };
6550
+ }
6551
+ }
6467
6552
  export {
6468
6553
  CLIENT_TYPE,
6469
6554
  FetchTransport,