@sakupa/mcp 0.7.34 → 0.7.36

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 +918 -73
  2. package/dist/index.js +954 -76
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  readFileSync,
15
15
  realpathSync,
16
16
  renameSync,
17
+ rmdirSync,
17
18
  statSync,
18
19
  unlinkSync,
19
20
  writeFileSync
@@ -151,6 +152,14 @@ function updateProjectOutputDir(projectDir, outputDir) {
151
152
  writeMarkerAtomically(canonical, marker);
152
153
  return marker;
153
154
  }
155
+ function deleteProjectMarker(projectDir) {
156
+ const path = projectMarkerPath(projectDir);
157
+ if (existsSync(path)) unlinkSync(path);
158
+ try {
159
+ rmdirSync(join(projectDir, SAKUPA_DIR));
160
+ } catch {
161
+ }
162
+ }
154
163
  function canonicalProjectDirectory(path) {
155
164
  const canonical = canonicalExistingPath(resolve(path));
156
165
  if (!statSync(canonical).isDirectory()) {
@@ -370,7 +379,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
370
379
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
371
380
 
372
381
  // ../core/dist/domain/version.js
373
- var SAKUPA_MCP_VERSION = "0.7.34";
382
+ var SAKUPA_MCP_VERSION = "0.7.36";
374
383
 
375
384
  // ../core/dist/domain/errors.js
376
385
  var HTTP_STATUS = {
@@ -929,9 +938,9 @@ var HttpApiClient = class {
929
938
  };
930
939
 
931
940
  // src/tools/definitions.ts
932
- import { randomUUID as randomUUID2 } from "node:crypto";
941
+ import { randomUUID as randomUUID3 } from "node:crypto";
933
942
  import { promises as fs2 } from "node:fs";
934
- import { join as join6, resolve as resolve4 } from "node:path";
943
+ import { join as join6, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
935
944
  import { z as z2 } from "zod";
936
945
 
937
946
  // src/analyze/analyzer.ts
@@ -1369,7 +1378,7 @@ import {
1369
1378
  existsSync as existsSync2,
1370
1379
  mkdirSync as mkdirSync2,
1371
1380
  readFileSync as readFileSync2,
1372
- rmdirSync,
1381
+ rmdirSync as rmdirSync2,
1373
1382
  rmSync,
1374
1383
  writeFileSync as writeFileSync2
1375
1384
  } from "node:fs";
@@ -1496,7 +1505,7 @@ function deleteSiteFile(projectDir) {
1496
1505
  rmSync(path, { force: true });
1497
1506
  }
1498
1507
  try {
1499
- rmdirSync(join3(projectDir, SITE_DIR));
1508
+ rmdirSync2(join3(projectDir, SITE_DIR));
1500
1509
  } catch {
1501
1510
  }
1502
1511
  }
@@ -1940,15 +1949,15 @@ function strFromU8(dat, latin1) {
1940
1949
  var slzh = function(d, b) {
1941
1950
  return b + 30 + b2(d, b + 26) + b2(d, b + 28);
1942
1951
  };
1943
- var zh = function(d, b, z5) {
1952
+ var zh = function(d, b, z6) {
1944
1953
  var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl;
1945
- var _a2 = z64hs(d, es, efl, z5, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
1954
+ var _a2 = z64hs(d, es, efl, z6, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
1946
1955
  return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
1947
1956
  };
1948
- var z64hs = function(d, b, l, z5, sc, su, off) {
1957
+ var z64hs = function(d, b, l, z6, sc, su, off) {
1949
1958
  var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
1950
1959
  var nf = nsc + nsu + noff;
1951
- if (z5 && nf) {
1960
+ if (z6 && nf) {
1952
1961
  for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
1953
1962
  if (b2(d, b) == 1) {
1954
1963
  return [
@@ -1959,7 +1968,7 @@ var z64hs = function(d, b, l, z5, sc, su, off) {
1959
1968
  ];
1960
1969
  }
1961
1970
  }
1962
- if (z5 < 2)
1971
+ if (z6 < 2)
1963
1972
  err(13);
1964
1973
  }
1965
1974
  return [sc, su, off, 0];
@@ -1976,18 +1985,18 @@ function unzipSync(data, opts) {
1976
1985
  if (!c)
1977
1986
  return {};
1978
1987
  var o = b4(data, e + 16);
1979
- var z5 = b4(data, e - 20) == 117853008;
1980
- if (z5) {
1988
+ var z6 = b4(data, e - 20) == 117853008;
1989
+ if (z6) {
1981
1990
  var ze = b4(data, e - 12);
1982
- z5 = b4(data, ze) == 101075792;
1983
- if (z5) {
1991
+ z6 = b4(data, ze) == 101075792;
1992
+ if (z6) {
1984
1993
  c = b4(data, ze + 32);
1985
1994
  o = b4(data, ze + 48);
1986
1995
  }
1987
1996
  }
1988
1997
  var fltr = opts && opts.filter;
1989
1998
  for (var i = 0; i < c; ++i) {
1990
- var _a2 = zh(data, o, z5), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
1999
+ var _a2 = zh(data, o, z6), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
1991
2000
  o = no;
1992
2001
  if (!fltr || fltr({
1993
2002
  name: fn,
@@ -2344,6 +2353,259 @@ ${diag.layers}
2344
2353
  var MCP_VERSION = SAKUPA_MCP_VERSION;
2345
2354
  var CLIENT_TYPE = "sakupa-mcp";
2346
2355
 
2356
+ // src/project-binding.ts
2357
+ import { fileURLToPath } from "node:url";
2358
+ import { resolve as resolve4 } from "node:path";
2359
+ var ProjectBindingError = class extends Error {
2360
+ diagnostics;
2361
+ constructor(diagnostics) {
2362
+ super(diagnostics.guidance);
2363
+ this.name = "ProjectBindingError";
2364
+ this.diagnostics = diagnostics;
2365
+ }
2366
+ };
2367
+ var ProjectBindingResolver = class {
2368
+ constructor(processCwd, rootsProvider) {
2369
+ this.processCwd = processCwd;
2370
+ this.rootsProvider = rootsProvider;
2371
+ }
2372
+ bound;
2373
+ boundState;
2374
+ resolving;
2375
+ async resolve() {
2376
+ if (this.bound) return this.bound;
2377
+ if (this.resolving) return this.resolving;
2378
+ this.resolving = this.inspect().then((inspection) => {
2379
+ if (!inspection.selected) throw new ProjectBindingError(inspection.diagnostics);
2380
+ this.bound = inspection.selected;
2381
+ this.boundState = inspection.diagnostics;
2382
+ return inspection.selected;
2383
+ }).finally(() => {
2384
+ this.resolving = void 0;
2385
+ });
2386
+ return this.resolving;
2387
+ }
2388
+ async diagnose() {
2389
+ if (this.bound) return this.boundState ?? boundDiagnostics(this.processCwd, this.bound);
2390
+ const inspection = await this.inspect();
2391
+ if (inspection.selected) {
2392
+ this.bound = inspection.selected;
2393
+ this.boundState = inspection.diagnostics;
2394
+ }
2395
+ return inspection.diagnostics;
2396
+ }
2397
+ async initialize() {
2398
+ if (this.bound) return this.bound;
2399
+ const inspection = await this.inspect(true);
2400
+ if (inspection.selected) {
2401
+ this.bound = inspection.selected;
2402
+ this.boundState = inspection.diagnostics;
2403
+ return inspection.selected;
2404
+ }
2405
+ if (!inspection.initializableRoot) {
2406
+ throw new ProjectBindingError(inspection.diagnostics);
2407
+ }
2408
+ const initialized = initializeProject(inspection.initializableRoot);
2409
+ this.bound = { ...initialized, bindingSource: "mcp_root" };
2410
+ this.boundState = boundDiagnostics(
2411
+ this.processCwd,
2412
+ this.bound,
2413
+ { supported: true, roots: [] },
2414
+ inspection.diagnostics.rootCandidates
2415
+ );
2416
+ return this.bound;
2417
+ }
2418
+ async inspect(forInitialization = false) {
2419
+ const snapshot = await safeRootsSnapshot(this.rootsProvider);
2420
+ const rootCandidates = snapshot.roots.map(inspectRoot);
2421
+ const initializedRoots = rootCandidates.filter(
2422
+ (candidate) => candidate.initialized && candidate.path !== void 0
2423
+ );
2424
+ if (snapshot.supported && snapshot.error) {
2425
+ return {
2426
+ diagnostics: diagnostic(
2427
+ "roots_request_failed",
2428
+ snapshot,
2429
+ this.processCwd,
2430
+ rootCandidates,
2431
+ "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."
2432
+ )
2433
+ };
2434
+ }
2435
+ if (initializedRoots.length === 1) {
2436
+ const initializedRoot = initializedRoots[0];
2437
+ if (!initializedRoot) throw new Error("initialized Root disappeared during resolution");
2438
+ const project = resolveLockedProjectRoot(initializedRoot.path);
2439
+ const selected = { ...project, bindingSource: "mcp_root" };
2440
+ return {
2441
+ selected,
2442
+ diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
2443
+ };
2444
+ }
2445
+ if (initializedRoots.length > 1) {
2446
+ return {
2447
+ diagnostics: diagnostic(
2448
+ "multiple_initialized_roots",
2449
+ snapshot,
2450
+ this.processCwd,
2451
+ rootCandidates,
2452
+ "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."
2453
+ )
2454
+ };
2455
+ }
2456
+ const validRoots = rootCandidates.filter(
2457
+ (candidate) => candidate.path !== void 0 && candidate.problem === void 0
2458
+ );
2459
+ if (forInitialization && snapshot.supported) {
2460
+ if (validRoots.length === 1) {
2461
+ const validRoot = validRoots[0];
2462
+ if (!validRoot) throw new Error("workspace Root disappeared during initialization");
2463
+ return {
2464
+ initializableRoot: validRoot.path,
2465
+ diagnostics: diagnostic(
2466
+ "workspace_not_initialized",
2467
+ snapshot,
2468
+ this.processCwd,
2469
+ rootCandidates,
2470
+ `The active MCP workspace ${validRoot.path} is ready to initialize.`
2471
+ )
2472
+ };
2473
+ }
2474
+ if (validRoots.length > 1) {
2475
+ return {
2476
+ diagnostics: diagnostic(
2477
+ "multiple_uninitialized_roots",
2478
+ snapshot,
2479
+ this.processCwd,
2480
+ rootCandidates,
2481
+ "The IDE exposes multiple uninitialized workspace Roots. Open only the intended project before calling init; Sakupa will not choose a directory for the user."
2482
+ )
2483
+ };
2484
+ }
2485
+ }
2486
+ if (snapshot.supported && validRoots.length === 1) {
2487
+ const validRoot = validRoots[0];
2488
+ if (!validRoot) throw new Error("workspace Root disappeared during diagnosis");
2489
+ return {
2490
+ diagnostics: diagnostic(
2491
+ "workspace_not_initialized",
2492
+ snapshot,
2493
+ this.processCwd,
2494
+ rootCandidates,
2495
+ `The IDE workspace ${validRoot.path} is not initialized. Call init with no path arguments; it will create .sakupa directly in that workspace Root.`
2496
+ )
2497
+ };
2498
+ }
2499
+ if (snapshot.supported && validRoots.length > 1) {
2500
+ return {
2501
+ diagnostics: diagnostic(
2502
+ "multiple_uninitialized_roots",
2503
+ snapshot,
2504
+ this.processCwd,
2505
+ rootCandidates,
2506
+ "The IDE exposes multiple uninitialized workspace Roots. Open only the intended project, then call init. Sakupa will not guess a project directory."
2507
+ )
2508
+ };
2509
+ }
2510
+ if (snapshot.supported) {
2511
+ return {
2512
+ diagnostics: diagnostic(
2513
+ "workspace_not_initialized",
2514
+ snapshot,
2515
+ this.processCwd,
2516
+ rootCandidates,
2517
+ "The IDE did not expose one usable file workspace Root. Open exactly one local project workspace, then retry help before calling init or deploy."
2518
+ )
2519
+ };
2520
+ }
2521
+ try {
2522
+ const cwdProject = resolveLockedProjectRoot(this.processCwd);
2523
+ const selected = { ...cwdProject, bindingSource: "process_cwd" };
2524
+ return {
2525
+ selected,
2526
+ diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
2527
+ };
2528
+ } catch {
2529
+ }
2530
+ const cwdProblem = inspectDirectory(this.processCwd);
2531
+ return {
2532
+ diagnostics: diagnostic(
2533
+ cwdProblem.problem ? "invalid_process_cwd" : "process_cwd_is_not_workspace",
2534
+ snapshot,
2535
+ this.processCwd,
2536
+ rootCandidates,
2537
+ "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 `npx -y @sakupa/mcp@latest init` with no path arguments from the intended project terminal, then configure/restart the MCP process in that directory."
2538
+ )
2539
+ };
2540
+ }
2541
+ };
2542
+ function fileRootUriToPath(uri, windows = process.platform === "win32") {
2543
+ const parsed = new URL(uri);
2544
+ if (parsed.protocol !== "file:") throw new Error("Root URI is not a file URI");
2545
+ return fileURLToPath(parsed, { windows });
2546
+ }
2547
+ async function safeRootsSnapshot(provider) {
2548
+ if (!provider) return { supported: false, roots: [] };
2549
+ try {
2550
+ return await provider();
2551
+ } catch (error) {
2552
+ return {
2553
+ supported: true,
2554
+ roots: [],
2555
+ error: error instanceof Error ? error.message : String(error)
2556
+ };
2557
+ }
2558
+ }
2559
+ function inspectRoot(root) {
2560
+ try {
2561
+ const path = canonicalProjectDirectory(fileRootUriToPath(root.uri));
2562
+ const marker = loadProjectMarker(path);
2563
+ return {
2564
+ uri: root.uri,
2565
+ ...root.name !== void 0 ? { name: root.name } : {},
2566
+ path,
2567
+ initialized: marker.kind === "ok",
2568
+ ...marker.kind === "corrupted" ? { problem: marker.problem } : {}
2569
+ };
2570
+ } catch (error) {
2571
+ return {
2572
+ uri: root.uri,
2573
+ ...root.name !== void 0 ? { name: root.name } : {},
2574
+ initialized: false,
2575
+ problem: error instanceof Error ? error.message : String(error)
2576
+ };
2577
+ }
2578
+ }
2579
+ function inspectDirectory(path) {
2580
+ try {
2581
+ return { path: canonicalProjectDirectory(resolve4(path)) };
2582
+ } catch (error) {
2583
+ return { problem: error instanceof Error ? error.message : String(error) };
2584
+ }
2585
+ }
2586
+ function diagnostic(diagnosisCode, snapshot, processCwd, rootCandidates, guidance) {
2587
+ return {
2588
+ diagnosisCode,
2589
+ mcpRootsSupported: snapshot.supported,
2590
+ processCwd,
2591
+ rootCandidates,
2592
+ guidance,
2593
+ reportRecommended: false
2594
+ };
2595
+ }
2596
+ function boundDiagnostics(processCwd, selected, snapshot = { supported: false, roots: [] }, rootCandidates = []) {
2597
+ return {
2598
+ diagnosisCode: "project_bound",
2599
+ mcpRootsSupported: snapshot.supported,
2600
+ processCwd,
2601
+ rootCandidates,
2602
+ selectedProjectDir: selected.projectDir,
2603
+ bindingSource: selected.bindingSource,
2604
+ guidance: `Sakupa is locked to ${selected.projectDir} from ${selected.bindingSource}.`,
2605
+ reportRecommended: false
2606
+ };
2607
+ }
2608
+
2347
2609
  // src/tools/result.ts
2348
2610
  import { z } from "zod";
2349
2611
  var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
@@ -2386,22 +2648,39 @@ function structuredToolResult(envelope) {
2386
2648
  }
2387
2649
 
2388
2650
  // src/tools/context.ts
2651
+ import { randomUUID as randomUUID2 } from "node:crypto";
2389
2652
  var LocalGuidanceError = class extends SakupaError {
2390
2653
  constructor(code, message) {
2391
2654
  super(code, message);
2392
2655
  }
2393
2656
  };
2394
- function withProjectDir(ctx) {
2657
+ var fallbackResolvers = /* @__PURE__ */ new WeakMap();
2658
+ var reportAuthorizations = /* @__PURE__ */ new WeakMap();
2659
+ function resolverFor(ctx) {
2660
+ if (ctx.projectBinding) return ctx.projectBinding;
2661
+ let resolver = fallbackResolvers.get(ctx);
2662
+ if (!resolver) {
2663
+ resolver = new ProjectBindingResolver(ctx.projectDir, ctx.rootsProvider);
2664
+ fallbackResolvers.set(ctx, resolver);
2665
+ }
2666
+ return resolver;
2667
+ }
2668
+ async function withProjectDir(ctx) {
2395
2669
  try {
2396
- const resolved = resolveLockedProjectRoot(ctx.projectDir);
2670
+ const binding = await resolverFor(ctx).resolve();
2671
+ const resolved = resolveLockedProjectRoot(binding.projectDir);
2397
2672
  return {
2398
2673
  ...ctx,
2399
2674
  projectDir: resolved.projectDir,
2400
2675
  requestedPath: resolved.requestedPath,
2401
2676
  markerKind: resolved.markerKind,
2402
- projectMarker: resolved.marker
2677
+ projectMarker: resolved.marker,
2678
+ bindingSource: binding.bindingSource
2403
2679
  };
2404
2680
  } catch (error) {
2681
+ if (error instanceof ProjectBindingError) {
2682
+ throw new LocalGuidanceError("not_found", error.message);
2683
+ }
2405
2684
  if (error instanceof ProjectRootError) {
2406
2685
  throw new LocalGuidanceError(
2407
2686
  error.code === "not_initialized" ? "not_found" : "invalid_request",
@@ -2411,6 +2690,60 @@ function withProjectDir(ctx) {
2411
2690
  throw error;
2412
2691
  }
2413
2692
  }
2693
+ async function diagnoseProjectBinding(ctx) {
2694
+ return resolverFor(ctx).diagnose();
2695
+ }
2696
+ async function initializeWorkspaceProject(ctx) {
2697
+ try {
2698
+ const resolved = await resolverFor(ctx).initialize();
2699
+ return {
2700
+ ...ctx,
2701
+ projectDir: resolved.projectDir,
2702
+ requestedPath: resolved.requestedPath,
2703
+ markerKind: resolved.markerKind,
2704
+ projectMarker: resolved.marker,
2705
+ bindingSource: resolved.bindingSource
2706
+ };
2707
+ } catch (error) {
2708
+ if (error instanceof ProjectBindingError) {
2709
+ throw new LocalGuidanceError("invalid_request", error.message);
2710
+ }
2711
+ throw error;
2712
+ }
2713
+ }
2714
+ async function optionalProjectContext(ctx) {
2715
+ try {
2716
+ return await withProjectDir(ctx);
2717
+ } catch {
2718
+ return null;
2719
+ }
2720
+ }
2721
+ function reportAuthorizationStore(ctx) {
2722
+ let store = reportAuthorizations.get(ctx);
2723
+ if (!store) {
2724
+ store = /* @__PURE__ */ new Map();
2725
+ reportAuthorizations.set(ctx, store);
2726
+ }
2727
+ return store;
2728
+ }
2729
+ function issueReportAuthorization(ctx, failedTool) {
2730
+ const token = randomUUID2();
2731
+ reportAuthorizationStore(ctx).set(token, {
2732
+ failedTool,
2733
+ expiresAt: Date.now() + 10 * 60 * 1e3
2734
+ });
2735
+ return token;
2736
+ }
2737
+ function requireReportAuthorization(ctx, token, failedTool) {
2738
+ const authorization = token ? reportAuthorizationStore(ctx).get(token) : void 0;
2739
+ if (!authorization || authorization.expiresAt < Date.now() || authorization.failedTool !== failedTool) {
2740
+ if (token) reportAuthorizationStore(ctx).delete(token);
2741
+ throw new LocalGuidanceError(
2742
+ "invalid_request",
2743
+ 'report is the last resort. Run help with topic:"diagnose", failedTool and the error code first. Only when help returns reportRecommended:true, copy its helpAuthorization into report.'
2744
+ );
2745
+ }
2746
+ }
2414
2747
  function requireSiteFile(ctx) {
2415
2748
  const state = loadSiteFile(ctx.projectDir);
2416
2749
  if (state.kind === "corrupted") {
@@ -2449,7 +2782,7 @@ function toolError(e) {
2449
2782
  ) : void 0;
2450
2783
  const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
2451
2784
  const serverGuidance = isSakupaError(e) && errorCode !== "internal" && errorCode !== "unauthorized" && errorCode !== "upgrade_required" && e.message.trim().length > 0 ? e.message : void 0;
2452
- const safeSummary = 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 will not help. Tell the user briefly, then offer to file it: run the report tool to send Sakupa a diagnostic report of what was being attempted so we can investigate." : "The operation failed; no server-internal details are exposed.");
2785
+ const safeSummary = 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.");
2453
2786
  const result = structuredToolResult({
2454
2787
  schemaVersion: 1,
2455
2788
  outcome: "failed",
@@ -2460,7 +2793,14 @@ function toolError(e) {
2460
2793
  retryable,
2461
2794
  ...safeDetails && Object.keys(safeDetails).length > 0 ? { details: safeDetails } : {}
2462
2795
  },
2463
- nextActions: []
2796
+ nextActions: [
2797
+ {
2798
+ tool: "help",
2799
+ arguments: { topic: "diagnose", failedTool: "unknown", errorCode },
2800
+ allowed: true,
2801
+ reasonCode: "diagnose_before_report"
2802
+ }
2803
+ ]
2464
2804
  });
2465
2805
  return { ...result, isError: true };
2466
2806
  }
@@ -2620,6 +2960,27 @@ If this list is stale (sites deleted or subscribed from another machine), remove
2620
2960
  "blocked"
2621
2961
  );
2622
2962
  }
2963
+ function outputDirectoryChain(projectRoot, outputAbs) {
2964
+ const rel = relative3(projectRoot, outputAbs);
2965
+ if (rel === "" || rel === ".") return [];
2966
+ if (rel === ".." || rel.startsWith(`..${sep4}`)) return [];
2967
+ const chain = [];
2968
+ let cursor = projectRoot;
2969
+ for (const part of rel.split(sep4).filter(Boolean)) {
2970
+ cursor = join6(cursor, part);
2971
+ chain.push(cursor);
2972
+ }
2973
+ return chain;
2974
+ }
2975
+ async function sakupaDirectoryEntries(projectDir) {
2976
+ try {
2977
+ return await fs2.readdir(join6(projectDir, ".sakupa"));
2978
+ } catch (error) {
2979
+ const code = error.code;
2980
+ if (code === "ENOENT") return [];
2981
+ throw error;
2982
+ }
2983
+ }
2623
2984
  function registerTools(server, baseCtx) {
2624
2985
  const previewHostPattern = previewHostPatternFor(baseCtx.apiBaseUrl);
2625
2986
  server.registerTool(
@@ -2634,7 +2995,7 @@ function registerTools(server, baseCtx) {
2634
2995
  },
2635
2996
  async (args) => {
2636
2997
  try {
2637
- const ctx = withProjectDir(baseCtx);
2998
+ const ctx = await withProjectDir(baseCtx);
2638
2999
  const analysis = await analyzeProject(ctx.projectDir, {
2639
3000
  ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
2640
3001
  });
@@ -2662,6 +3023,9 @@ Next action: ${analysis.suggestedNextAction}`,
2662
3023
  outputDirChangeConfirmed: z2.boolean().optional().describe(
2663
3024
  "Required only when changing the previously successful publish directory. Confirm only after showing the old and new directories to the user."
2664
3025
  ),
3026
+ sakupaRelocationConfirmed: z2.boolean().optional().describe(
3027
+ "Required only when a nested directory is itself initialized with .sakupa/project.json. Confirm only after showing the source and authoritative project Root; Sakupa then migrates non-conflicting state without exposing credentials."
3028
+ ),
2665
3029
  spaFallback: z2.boolean().optional().describe(
2666
3030
  "Override automatic SPA-fallback detection (single index.html + JS auto-enables rewriting unknown paths to index.html; multiple HTML pages auto-disable it). Pass only to force the behavior against the detected structure."
2667
3031
  ),
@@ -2676,14 +3040,14 @@ Next action: ${analysis.suggestedNextAction}`,
2676
3040
  },
2677
3041
  async (args) => {
2678
3042
  try {
2679
- const ctx = withProjectDir(baseCtx);
3043
+ const ctx = await withProjectDir(baseCtx);
2680
3044
  const analysis = await analyzeProject(ctx.projectDir, { outputDir: args.outputDir });
2681
3045
  if (!analysis.deployable || !analysis.files) {
2682
3046
  return notDeployableResult(analysis);
2683
3047
  }
2684
3048
  const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
2685
3049
  const recordedOutputDir = ctx.projectMarker?.outputDir;
2686
- if (recordedOutputDir !== void 0 && resolve4(ctx.projectDir, recordedOutputDir) !== resolve4(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
3050
+ if (recordedOutputDir !== void 0 && resolve5(ctx.projectDir, recordedOutputDir) !== resolve5(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
2687
3051
  return structuredToolResult({
2688
3052
  schemaVersion: 1,
2689
3053
  outcome: "waiting_user",
@@ -2699,7 +3063,7 @@ Next action: ${analysis.suggestedNextAction}`,
2699
3063
  });
2700
3064
  }
2701
3065
  const files = analysis.files;
2702
- const outputAbs = resolve4(ctx.projectDir, effectiveOutputDir);
3066
+ const outputAbs = resolve5(ctx.projectDir, effectiveOutputDir);
2703
3067
  const manifest = await buildHashedManifest(files, outputAbs);
2704
3068
  const siteFileState = loadSiteFile(ctx.projectDir);
2705
3069
  if (siteFileState.kind === "corrupted") {
@@ -2713,30 +3077,157 @@ Next action: ${analysis.suggestedNextAction}`,
2713
3077
  );
2714
3078
  }
2715
3079
  let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
2716
- let credentialRelocatedFrom = null;
2717
- if (!existing && effectiveOutputDir !== ".") {
2718
- const outputProjectMarker = loadProjectMarker(outputAbs);
2719
- if (outputProjectMarker.kind !== "absent") {
3080
+ const credentialRelocatedFrom = [];
3081
+ const markerRelocatedFrom = [];
3082
+ const nestedSiteFiles = [];
3083
+ const nestedRecoveryFiles = [];
3084
+ for (const candidateDir of outputDirectoryChain(ctx.projectDir, outputAbs)) {
3085
+ const entries = await sakupaDirectoryEntries(candidateDir);
3086
+ if (entries.length === 0) continue;
3087
+ const unknownEntries = entries.filter(
3088
+ (entry) => !["project.json", "site.json", "recovery.json"].includes(entry)
3089
+ );
3090
+ if (unknownEntries.length > 0) {
2720
3091
  return text(
2721
- "publish_directory_is_independent_project",
2722
- outputProjectMarker.kind === "corrupted" ? `The selected publish directory ${outputAbs} contains a damaged Sakupa project marker: ${outputProjectMarker.problem}. Nothing was deployed.` : `The selected publish directory ${outputAbs} is itself an explicitly initialized Sakupa project. Refusing to move or reuse its credential from ${ctx.projectDir}. Run deploy from that independent project instead, or choose a publish directory that is not another Sakupa project.`,
2723
- { projectRoot: ctx.projectDir, outputDir: effectiveOutputDir },
3092
+ "nested_sakupa_contains_unknown_files",
3093
+ `A nested .sakupa directory at ${candidateDir} contains unknown files ${JSON.stringify(unknownEntries)}. Nothing was moved or deployed. Run help; Sakupa will never delete unrecognized user files.`,
3094
+ { projectRoot: ctx.projectDir, nestedDirectory: candidateDir, unknownEntries },
2724
3095
  "blocked"
2725
3096
  );
2726
3097
  }
2727
- const outputSiteState = loadSiteFile(outputAbs);
2728
- if (outputSiteState.kind === "corrupted") {
3098
+ const nestedMarker = loadProjectMarker(candidateDir);
3099
+ if (nestedMarker.kind === "corrupted") {
2729
3100
  return text(
2730
- "output_site_file_corrupted",
2731
- `A misplaced .sakupa/site.json exists in output directory ${outputAbs}, but it is damaged: ${outputSiteState.problem}. Repair that file before retrying with the MCP still opened at ${ctx.projectDir}. Nothing was deployed and no site was created.`,
2732
- { projectRoot: ctx.projectDir, outputDir: analysis.recommendedOutputDir },
3101
+ "nested_project_marker_corrupted",
3102
+ `The nested .sakupa/project.json at ${candidateDir} is damaged: ${nestedMarker.problem}. Nothing was moved or deployed.`,
3103
+ { projectRoot: ctx.projectDir, nestedDirectory: candidateDir },
2733
3104
  "blocked"
2734
3105
  );
2735
3106
  }
2736
- if (outputSiteState.kind === "ok") {
2737
- existing = outputSiteState.file;
2738
- credentialRelocatedFrom = outputAbs;
3107
+ if (nestedMarker.kind === "ok") {
3108
+ if (args.sakupaRelocationConfirmed !== true) {
3109
+ return structuredToolResult({
3110
+ schemaVersion: 1,
3111
+ outcome: "waiting_user",
3112
+ resultCode: "sakupa_relocation_confirmation_required",
3113
+ 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.`,
3114
+ data: {
3115
+ projectRoot: ctx.projectDir,
3116
+ misplacedSakupaDirectory: join6(candidateDir, ".sakupa"),
3117
+ targetSakupaDirectory: join6(ctx.projectDir, ".sakupa"),
3118
+ confirmationField: "sakupaRelocationConfirmed"
3119
+ },
3120
+ nextActions: [
3121
+ {
3122
+ tool: "deploy",
3123
+ allowed: true,
3124
+ reasonCode: "explicit_sakupa_relocation_confirmation"
3125
+ }
3126
+ ]
3127
+ });
3128
+ }
3129
+ markerRelocatedFrom.push(candidateDir);
2739
3130
  }
3131
+ const nestedSite = loadSiteFile(candidateDir);
3132
+ if (nestedSite.kind === "corrupted") {
3133
+ return text(
3134
+ "output_site_file_corrupted",
3135
+ `A misplaced .sakupa/site.json exists at ${candidateDir}, but it is damaged: ${nestedSite.problem}. Nothing was moved or deployed.`,
3136
+ { projectRoot: ctx.projectDir, nestedDirectory: candidateDir },
3137
+ "blocked"
3138
+ );
3139
+ }
3140
+ if (nestedSite.kind === "ok")
3141
+ nestedSiteFiles.push({ dir: candidateDir, file: nestedSite.file });
3142
+ const nestedRecovery = loadRecoveryFile(candidateDir);
3143
+ if (nestedRecovery) nestedRecoveryFiles.push({ dir: candidateDir, file: nestedRecovery });
3144
+ }
3145
+ const allSiteFiles = [
3146
+ ...existing ? [{ dir: ctx.projectDir, file: existing }] : [],
3147
+ ...nestedSiteFiles
3148
+ ];
3149
+ const uniqueSiteBindings = new Set(
3150
+ allSiteFiles.map(({ file }) => `${file.siteId}\0${file.credential}`)
3151
+ );
3152
+ if (uniqueSiteBindings.size > 1) {
3153
+ return text(
3154
+ "sakupa_relocation_conflict",
3155
+ "The project Root and nested publish path contain different Sakupa site bindings. Nothing was moved or deployed; run help. Sakupa will never overwrite one site credential with another.",
3156
+ {
3157
+ projectRoot: ctx.projectDir,
3158
+ conflictingDirectories: allSiteFiles.map(({ dir }) => dir)
3159
+ },
3160
+ "blocked"
3161
+ );
3162
+ }
3163
+ if (!existing && nestedSiteFiles[0]) existing = nestedSiteFiles[0].file;
3164
+ credentialRelocatedFrom.push(...nestedSiteFiles.map(({ dir }) => dir));
3165
+ const rootRecovery = loadRecoveryFile(ctx.projectDir);
3166
+ const allRecoveries = [
3167
+ ...rootRecovery ? [{ dir: ctx.projectDir, file: rootRecovery }] : [],
3168
+ ...nestedRecoveryFiles
3169
+ ];
3170
+ const uniqueRecoveries = new Set(
3171
+ allRecoveries.map(({ file }) => `${file.verificationId}\0${file.credential}`)
3172
+ );
3173
+ if (uniqueRecoveries.size > 1) {
3174
+ return text(
3175
+ "recovery_relocation_conflict",
3176
+ "The project Root and nested publish path contain different recovery credentials. Nothing was moved or deployed; run help. Sakupa will not restart DNS recovery.",
3177
+ {
3178
+ projectRoot: ctx.projectDir,
3179
+ conflictingDirectories: allRecoveries.map(({ dir }) => dir)
3180
+ },
3181
+ "blocked"
3182
+ );
3183
+ }
3184
+ const localCredentials = /* @__PURE__ */ new Set([
3185
+ ...allSiteFiles.map(({ file }) => file.credential),
3186
+ ...allRecoveries.map(({ file }) => file.credential)
3187
+ ]);
3188
+ if (allSiteFiles.length > 0 && allRecoveries.length > 0 && localCredentials.size > 1) {
3189
+ return text(
3190
+ "site_recovery_relocation_conflict",
3191
+ "The project Root and nested publish path contain site and recovery credentials that do not belong to the same recovered site. Nothing was moved or deployed; run help. Sakupa will never merge unrelated credentials.",
3192
+ {
3193
+ projectRoot: ctx.projectDir,
3194
+ siteDirectories: allSiteFiles.map(({ dir }) => dir),
3195
+ recoveryDirectories: allRecoveries.map(({ dir }) => dir)
3196
+ },
3197
+ "blocked"
3198
+ );
3199
+ }
3200
+ if (nestedRecoveryFiles.length > 0) {
3201
+ const recovery = rootRecovery ?? nestedRecoveryFiles[0]?.file;
3202
+ if (!recovery) throw new Error("recovery relocation lost its source state");
3203
+ if (!rootRecovery) writeRecoveryFile(ctx.projectDir, recovery);
3204
+ for (const { dir } of nestedRecoveryFiles) deleteRecoveryFile(dir);
3205
+ for (const dir of markerRelocatedFrom) deleteProjectMarker(dir);
3206
+ return structuredToolResult({
3207
+ schemaVersion: 1,
3208
+ outcome: "blocked",
3209
+ resultCode: "recovery_relocated_resume_required",
3210
+ summary: `Recovery state was moved safely to ${ctx.projectDir}/.sakupa before any deploy. The credential remains intact. Do not repeat DNS verification; call recover with action:"download" and outputDir:"${effectiveOutputDir}".`,
3211
+ data: {
3212
+ projectRoot: ctx.projectDir,
3213
+ relocatedFrom: nestedRecoveryFiles.map(({ dir }) => dir),
3214
+ credentialStoredLocally: true,
3215
+ dnsVerificationRepeated: false
3216
+ },
3217
+ nextActions: [
3218
+ {
3219
+ tool: "recover",
3220
+ arguments: { action: "download", outputDir: effectiveOutputDir },
3221
+ allowed: true,
3222
+ reasonCode: "credential_first_recovery_resume"
3223
+ }
3224
+ ]
3225
+ });
3226
+ }
3227
+ for (const dir of markerRelocatedFrom.filter(
3228
+ (candidate) => !credentialRelocatedFrom.includes(candidate)
3229
+ )) {
3230
+ deleteProjectMarker(dir);
2740
3231
  }
2741
3232
  if (!existing) {
2742
3233
  const barrier = freeSiteCreationBarrier(ctx.apiBaseUrl);
@@ -2847,7 +3338,10 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
2847
3338
  }
2848
3339
  const { uploaded, finalized } = update;
2849
3340
  writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
2850
- if (credentialRelocatedFrom !== null) deleteSiteFile(credentialRelocatedFrom);
3341
+ for (const source of credentialRelocatedFrom) {
3342
+ deleteSiteFile(source);
3343
+ if (markerRelocatedFrom.includes(source)) deleteProjectMarker(source);
3344
+ }
2851
3345
  updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
2852
3346
  noteSiteMode(existing.siteId, finalized.mode);
2853
3347
  return text(
@@ -2857,7 +3351,7 @@ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
2857
3351
  Project directory: ${ctx.projectDir}
2858
3352
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
2859
3353
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
2860
- ` : "") + (credentialRelocatedFrom ? `Credential binding relocated from ${credentialRelocatedFrom}/.sakupa to ${ctx.projectDir}/.sakupa; the existing site was preserved.
3354
+ ` : "") + (credentialRelocatedFrom.length > 0 ? `Credential binding relocated from ${credentialRelocatedFrom.join(", ")} to ${ctx.projectDir}/.sakupa; the existing site was preserved.
2861
3355
  ` : "") + (finalized.mode === "free" ? `
2862
3356
  Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh call. Subscribing (subscribe) makes the site permanent.
2863
3357
  ` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
@@ -2873,7 +3367,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
2873
3367
  filesUploaded: uploaded,
2874
3368
  totalBytes: finalized.totalBytes,
2875
3369
  warnings: finalized.warnings,
2876
- ...credentialRelocatedFrom ? { credentialRelocatedFrom } : {}
3370
+ ...credentialRelocatedFrom.length > 0 ? { credentialRelocatedFrom } : {}
2877
3371
  }
2878
3372
  );
2879
3373
  } catch (e) {
@@ -2891,7 +3385,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
2891
3385
  },
2892
3386
  async () => {
2893
3387
  try {
2894
- const ctx = withProjectDir(baseCtx);
3388
+ const ctx = await withProjectDir(baseCtx);
2895
3389
  const site = requireSiteFile(ctx);
2896
3390
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
2897
3391
  return text(
@@ -2915,7 +3409,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2915
3409
  },
2916
3410
  async () => {
2917
3411
  try {
2918
- const ctx = withProjectDir(baseCtx);
3412
+ const ctx = await withProjectDir(baseCtx);
2919
3413
  const site = requireSiteFile(ctx);
2920
3414
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
2921
3415
  noteSiteMode(res.siteId, res.mode);
@@ -2950,13 +3444,13 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2950
3444
  },
2951
3445
  async (args) => {
2952
3446
  try {
2953
- const ctx = withProjectDir(baseCtx);
3447
+ const ctx = await withProjectDir(baseCtx);
2954
3448
  const site = requireSiteFile(ctx);
2955
3449
  const res = await ctx.client.createPlanCheckout(
2956
3450
  {
2957
3451
  siteId: site.siteId,
2958
3452
  plan: args.plan,
2959
- idempotencyKey: randomUUID2()
3453
+ idempotencyKey: randomUUID3()
2960
3454
  },
2961
3455
  site.credential
2962
3456
  );
@@ -2998,7 +3492,7 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
2998
3492
  },
2999
3493
  async (args) => {
3000
3494
  try {
3001
- const ctx = withProjectDir(baseCtx);
3495
+ const ctx = await withProjectDir(baseCtx);
3002
3496
  const site = requireSiteFile(ctx);
3003
3497
  if (args.action === "status") {
3004
3498
  const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
@@ -3107,7 +3601,7 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
3107
3601
  },
3108
3602
  async () => {
3109
3603
  try {
3110
- const ctx = withProjectDir(baseCtx);
3604
+ const ctx = await withProjectDir(baseCtx);
3111
3605
  const site = requireSiteFile(ctx);
3112
3606
  const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
3113
3607
  noteSiteMode(res.siteId, res.mode);
@@ -3151,7 +3645,7 @@ Full status:`, res);
3151
3645
  async (args) => {
3152
3646
  try {
3153
3647
  if (args.scope === "site") {
3154
- const ctx = withProjectDir(baseCtx);
3648
+ const ctx = await withProjectDir(baseCtx);
3155
3649
  const site = requireSiteFile(ctx);
3156
3650
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
3157
3651
  return structuredToolResult({
@@ -3212,7 +3706,7 @@ Full status:`, res);
3212
3706
  },
3213
3707
  async (args) => {
3214
3708
  try {
3215
- const ctx = withProjectDir(baseCtx);
3709
+ const ctx = await withProjectDir(baseCtx);
3216
3710
  if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
3217
3711
  throw new LocalGuidanceError(
3218
3712
  "invalid_request",
@@ -3521,7 +4015,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3521
4015
  },
3522
4016
  async (args) => {
3523
4017
  try {
3524
- const ctx = withProjectDir(baseCtx);
4018
+ const ctx = await withProjectDir(baseCtx);
3525
4019
  const site = requireSiteFile(ctx);
3526
4020
  const res = await ctx.client.createTicket(site.credential, {
3527
4021
  siteId: site.siteId,
@@ -3543,11 +4037,12 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3543
4037
  server.registerTool(
3544
4038
  "report",
3545
4039
  {
3546
- description: "Prepare and submit a sanitized bug report when a Sakupa tool failed and the issue looks like a product bug. 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.",
4040
+ 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.",
3547
4041
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3548
4042
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3549
4043
  inputSchema: {
3550
4044
  toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy".'),
4045
+ helpAuthorization: z2.string().describe("Short-lived authorization returned only by help when report is recommended."),
3551
4046
  errorCode: z2.string().optional(),
3552
4047
  errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
3553
4048
  requestId: z2.string().optional(),
@@ -3565,8 +4060,9 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3565
4060
  },
3566
4061
  async (args) => {
3567
4062
  try {
3568
- const ctx = withProjectDir(baseCtx);
3569
- const siteState = loadSiteFile(ctx.projectDir);
4063
+ requireReportAuthorization(baseCtx, args.helpAuthorization, args.toolName);
4064
+ const ctx = await optionalProjectContext(baseCtx);
4065
+ const siteState = ctx ? loadSiteFile(ctx.projectDir) : { kind: "absent" };
3570
4066
  const site = siteState.kind === "ok" ? siteState.file : null;
3571
4067
  const diagnostics = {
3572
4068
  toolName: args.toolName,
@@ -3597,7 +4093,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3597
4093
  "preview"
3598
4094
  );
3599
4095
  }
3600
- const res = await ctx.client.reportBug(payload, site?.credential);
4096
+ const res = await baseCtx.client.reportBug(payload, site?.credential);
3601
4097
  return text(
3602
4098
  "bug_report_submitted",
3603
4099
  `Bug report submitted. Ticket: ${res.ticketId}
@@ -3650,7 +4146,7 @@ function registerBillingTools(server, baseCtx) {
3650
4146
  },
3651
4147
  async (args) => {
3652
4148
  try {
3653
- const ctx = withProjectDir(baseCtx);
4149
+ const ctx = await withProjectDir(baseCtx);
3654
4150
  const site = requireSiteFile(ctx);
3655
4151
  const result = await ctx.client.changeSubscriptionPlan(site.credential, {
3656
4152
  siteId: site.siteId,
@@ -3679,7 +4175,7 @@ function registerBillingTools(server, baseCtx) {
3679
4175
  }
3680
4176
 
3681
4177
  // src/tools/lifecycle.ts
3682
- import { randomUUID as randomUUID3 } from "node:crypto";
4178
+ import { randomUUID as randomUUID4 } from "node:crypto";
3683
4179
  import { z as z4 } from "zod";
3684
4180
  var deleteConfirmation = z4.object({
3685
4181
  siteId: z4.string().min(1),
@@ -3711,9 +4207,9 @@ function registerLifecycleTools(server, baseCtx) {
3711
4207
  },
3712
4208
  async (args) => {
3713
4209
  try {
3714
- const ctx = withProjectDir(baseCtx);
4210
+ const ctx = await withProjectDir(baseCtx);
3715
4211
  const site = requireSiteFile(ctx);
3716
- const operationId = args.operationId ?? randomUUID3();
4212
+ const operationId = args.operationId ?? randomUUID4();
3717
4213
  if (args.action === "preview") {
3718
4214
  const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
3719
4215
  operationId
@@ -3732,15 +4228,31 @@ function registerLifecycleTools(server, baseCtx) {
3732
4228
  ]
3733
4229
  });
3734
4230
  }
4231
+ const confirmArguments = {
4232
+ action: "confirm",
4233
+ operationId,
4234
+ confirmation: preview.confirmation
4235
+ };
4236
+ const confirmationJson = JSON.stringify(preview.confirmation);
3735
4237
  return structuredToolResult({
3736
4238
  schemaVersion: 1,
3737
4239
  outcome: "waiting_user",
3738
4240
  resultCode: "delete_confirmation_required",
3739
4241
  operationId,
3740
- summary: `Deletion consequences returned, bound to the current site and billing state; after confirmation the content and the permanent URL are unrecoverable. To proceed, call delete again with action:"confirm", operationId:"${operationId}", and the confirmation object from data.preview.confirmation.`,
3741
- data: { preview },
4242
+ summary: `Deletion consequences returned, bound to the current site and billing state; after confirmation the content and the permanent URL are unrecoverable. To proceed, call delete exactly once with these arguments (copy them verbatim; do not infer values): ${JSON.stringify(confirmArguments)}. Exact confirmation object: ${confirmationJson}`,
4243
+ data: { preview, confirmation: preview.confirmation, confirmArguments },
4244
+ userAction: {
4245
+ type: "confirm_in_mcp",
4246
+ expectedOutcome: "Permanently delete this site if its cloud state is unchanged.",
4247
+ resumeWith: { tool: "delete", arguments: confirmArguments }
4248
+ },
3742
4249
  nextActions: [
3743
- { tool: "delete", allowed: true, reasonCode: "exact_confirmation_required" }
4250
+ {
4251
+ tool: "delete",
4252
+ arguments: confirmArguments,
4253
+ allowed: true,
4254
+ reasonCode: "exact_confirmation_required"
4255
+ }
3744
4256
  ]
3745
4257
  });
3746
4258
  }
@@ -3778,6 +4290,314 @@ function registerLifecycleTools(server, baseCtx) {
3778
4290
  );
3779
4291
  }
3780
4292
 
4293
+ // src/tools/help.ts
4294
+ import { join as join7 } from "node:path";
4295
+ import { z as z5 } from "zod";
4296
+ var TOOL_TOPICS = [
4297
+ "init",
4298
+ "analyze",
4299
+ "deploy",
4300
+ "refresh",
4301
+ "status",
4302
+ "plans",
4303
+ "subscribe",
4304
+ "bind",
4305
+ "billing",
4306
+ "portal",
4307
+ "recover",
4308
+ "change",
4309
+ "delete",
4310
+ "support",
4311
+ "report",
4312
+ "help"
4313
+ ];
4314
+ var HELP_TOPICS = ["diagnose", "overview", ...TOOL_TOPICS];
4315
+ var TOOL_MANUALS = {
4316
+ init: {
4317
+ purpose: "Initialize the active IDE workspace as one Sakupa project.",
4318
+ sideEffects: "Creates only .sakupa/project.json locally; no API call, site or charge.",
4319
+ preconditions: "Exactly one usable MCP workspace Root. Clients without Roots use CLI init.",
4320
+ parameters: "No parameters and no path argument.",
4321
+ warnings: [
4322
+ "Never initialize the IDE installation directory.",
4323
+ "Existing site.json and recovery.json are preserved."
4324
+ ],
4325
+ nextStep: "Call analyze, then deploy with the exact relative outputDir."
4326
+ },
4327
+ analyze: {
4328
+ purpose: "Inspect a project or explicit output directory for safe static deployment.",
4329
+ sideEffects: "Read-only local file inspection; no API call.",
4330
+ preconditions: "An initialized, unambiguous project binding.",
4331
+ parameters: "Optional outputDir relative to the bound project Root.",
4332
+ warnings: ["Build locally first.", "Never publish source, secrets, server code or media."],
4333
+ nextStep: "Fix reported blockers, then call deploy with the exact outputDir."
4334
+ },
4335
+ deploy: {
4336
+ purpose: "Create or update the bound Sakupa static site.",
4337
+ sideEffects: "Reads local output, uploads files and may create a public free site.",
4338
+ preconditions: "Initialized project, exact outputDir and first-publication confirmation.",
4339
+ parameters: "outputDir is required and relative to the project Root.",
4340
+ warnings: [
4341
+ ".sakupa must remain at the project Root and is never uploaded.",
4342
+ "A changed outputDir requires explicit confirmation."
4343
+ ],
4344
+ nextStep: "Call status to verify the cloud result."
4345
+ },
4346
+ refresh: {
4347
+ purpose: "Extend a free site lifetime without uploading content.",
4348
+ sideEffects: "Updates the site expiry in Sakupa.",
4349
+ preconditions: "A valid local site credential.",
4350
+ parameters: "No parameters.",
4351
+ warnings: ["Subscribed sites are permanent and do not need refresh."],
4352
+ nextStep: "Call status to verify the new expiry."
4353
+ },
4354
+ status: {
4355
+ purpose: "Read the bound site, deployment, domain and serving state.",
4356
+ sideEffects: "Read-only API request.",
4357
+ preconditions: "A valid local site credential.",
4358
+ parameters: "No parameters.",
4359
+ warnings: ["Billing truth comes from billing, not inferred status text."],
4360
+ nextStep: "Follow only the returned real tool names."
4361
+ },
4362
+ plans: {
4363
+ purpose: "Read the authoritative hosting plan catalog and rules.",
4364
+ sideEffects: "Read-only public API request.",
4365
+ preconditions: "None; project initialization is not required.",
4366
+ parameters: "No parameters.",
4367
+ warnings: ["JPY prices and cloud plan order are authoritative."],
4368
+ nextStep: "Use subscribe for first payment or change for an existing subscription."
4369
+ },
4370
+ subscribe: {
4371
+ purpose: "Create Stripe Checkout for the first subscription.",
4372
+ sideEffects: "Creates a short-lived Stripe Checkout session; payment happens only on Stripe.",
4373
+ preconditions: "A free bound site with a valid credential.",
4374
+ parameters: "The selected plan from plans.",
4375
+ warnings: ["Creating a link does not subscribe or charge the user."],
4376
+ nextStep: "Show the complete URL, then query billing after Stripe confirmation."
4377
+ },
4378
+ bind: {
4379
+ purpose: "Start, check or inspect custom-domain binding.",
4380
+ sideEffects: "May create DNS verification and hostname provisioning state.",
4381
+ preconditions: "A subscribed site and DNS control.",
4382
+ parameters: "Action plus hostname or verificationId as returned by the prior step.",
4383
+ warnings: ["www is mandatory; the apex is optional.", "Copy DNS values verbatim."],
4384
+ nextStep: "Follow the returned DNS checklist and call bind status/check."
4385
+ },
4386
+ billing: {
4387
+ purpose: "Read the single authoritative subscription and usage snapshot.",
4388
+ sideEffects: "Read-only API reconciliation.",
4389
+ preconditions: "A valid bound site.",
4390
+ parameters: "No parameters.",
4391
+ warnings: ["Never infer renewal state from user wording or an old link."],
4392
+ nextStep: "Use change or portal only when the user wants billing management."
4393
+ },
4394
+ portal: {
4395
+ purpose: "Open Stripe billing/customer management or public recovery login.",
4396
+ sideEffects: "Creates or returns a Stripe-hosted management URL.",
4397
+ preconditions: "Site scope needs a credential; public recovery does not.",
4398
+ parameters: "Use the supported scope.",
4399
+ warnings: ["Opening a link does not change subscription state."],
4400
+ nextStep: "Query billing after the user confirms an operation in Stripe."
4401
+ },
4402
+ recover: {
4403
+ purpose: "Recover a paid custom-domain site credential and download its content.",
4404
+ sideEffects: "Creates DNS verification state and writes local credential/archive files.",
4405
+ preconditions: "DNS control of a domain bound to an active paid site.",
4406
+ parameters: "Use the returned action and verificationId; outputDir is relative to Root.",
4407
+ warnings: [
4408
+ "After site.json exists, resume download and never repeat DNS verification.",
4409
+ "Credential is saved before archive creation/download."
4410
+ ],
4411
+ nextStep: "Call recover download when local credentials already exist."
4412
+ },
4413
+ change: {
4414
+ purpose: "Open the unified Stripe subscription-management page.",
4415
+ sideEffects: "Creates a short-lived Portal session and audit record only.",
4416
+ preconditions: "An active subscription.",
4417
+ parameters: "No plan direction or target is accepted from conversational intent.",
4418
+ warnings: ["Only Stripe confirmation changes the subscription."],
4419
+ nextStep: "Call billing after the user finishes on Stripe."
4420
+ },
4421
+ delete: {
4422
+ purpose: "Preview and permanently delete a free site.",
4423
+ sideEffects: "Confirm permanently removes content, URL and local site credential.",
4424
+ preconditions: "Paid subscriptions must fully end and return the site to free mode first.",
4425
+ parameters: "Preview first; confirm with the exact returned confirmArguments.",
4426
+ warnings: [
4427
+ "Never guess timestamps or confirmation fields from status.",
4428
+ "Deletion is irreversible and does not secretly cancel payment."
4429
+ ],
4430
+ nextStep: "Use the preview userAction.resumeWith arguments verbatim."
4431
+ },
4432
+ support: {
4433
+ purpose: "Create a customer-service ticket for billing, refund, payment or domain assistance.",
4434
+ sideEffects: "Submits a support ticket.",
4435
+ preconditions: "A bound subscribed site and user-provided issue description.",
4436
+ parameters: "Category, subject, sanitized description and optional contact email.",
4437
+ warnings: ["Never include credentials, source, card data or secrets."],
4438
+ nextStep: "Wait for support follow-up."
4439
+ },
4440
+ report: {
4441
+ purpose: "Last-resort product bug report after help recommends it.",
4442
+ sideEffects: "Preview is local; confirmSubmit sends a sanitized diagnostic report.",
4443
+ preconditions: "Call help first and show the exact report preview to the user.",
4444
+ parameters: "Failed tool, helpAuthorization, sanitized diagnostics and explicit confirmSubmit.",
4445
+ warnings: [
4446
+ "Never report ordinary setup errors help can solve.",
4447
+ "Submission requires approval."
4448
+ ],
4449
+ nextStep: "Submit only after the user reviews the preview."
4450
+ },
4451
+ help: {
4452
+ purpose: "Diagnose the current MCP/project state or explain any Sakupa tool.",
4453
+ sideEffects: "Read-only local diagnosis; no API call or file write.",
4454
+ preconditions: "None; works even when project binding is broken.",
4455
+ parameters: "topic defaults to diagnose; use overview or a tool name for its manual.",
4456
+ warnings: ["Use help before repeating failed calls or suggesting report."],
4457
+ nextStep: "Follow the returned diagnosis and nextActions."
4458
+ }
4459
+ };
4460
+ function registerHelpTools(server, baseCtx) {
4461
+ server.registerTool(
4462
+ "init",
4463
+ {
4464
+ 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. Clients without MCP Roots must use the no-argument CLI init.",
4465
+ inputSchema: {},
4466
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4467
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
4468
+ },
4469
+ async () => {
4470
+ try {
4471
+ const ctx = await initializeWorkspaceProject(baseCtx);
4472
+ const marker = loadProjectMarker(ctx.projectDir);
4473
+ if (marker.kind !== "ok")
4474
+ throw new Error("init postcondition failed: project marker missing");
4475
+ const site = loadSiteFile(ctx.projectDir);
4476
+ const recovery = loadRecoveryFile(ctx.projectDir);
4477
+ const sakupaDirectory = join7(ctx.projectDir, ".sakupa");
4478
+ return structuredToolResult({
4479
+ schemaVersion: 1,
4480
+ outcome: "completed",
4481
+ resultCode: "project_initialized",
4482
+ summary: `Sakupa project initialized and verified at the active workspace Root: ${ctx.projectDir}. .sakupa is at ${sakupaDirectory}; no cloud site was created and no charge occurred.`,
4483
+ data: {
4484
+ projectRoot: ctx.projectDir,
4485
+ sakupaDirectory,
4486
+ projectId: marker.marker.projectId,
4487
+ bindingSource: ctx.bindingSource,
4488
+ sakupaAtProjectRoot: true,
4489
+ postconditionVerified: true,
4490
+ existingSitePreserved: site.kind !== "absent",
4491
+ existingRecoveryPreserved: recovery !== null,
4492
+ cloudApiCalled: false
4493
+ },
4494
+ nextActions: [{ tool: "analyze", allowed: true, reasonCode: "project_ready" }]
4495
+ });
4496
+ } catch (error) {
4497
+ return toolError(error);
4498
+ }
4499
+ }
4500
+ );
4501
+ server.registerTool(
4502
+ "help",
4503
+ {
4504
+ 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 or a tool name for complete usage, side effects, parameters and warnings. Only recommend report when help explicitly returns reportRecommended:true.",
4505
+ inputSchema: {
4506
+ topic: z5.enum(HELP_TOPICS).optional().default("diagnose"),
4507
+ failedTool: z5.string().optional(),
4508
+ errorCode: z5.string().optional(),
4509
+ resultCode: z5.string().optional(),
4510
+ requestId: z5.string().optional()
4511
+ },
4512
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4513
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
4514
+ },
4515
+ async (args) => {
4516
+ try {
4517
+ if (args.topic === "overview") {
4518
+ const catalog = Object.fromEntries(
4519
+ TOOL_TOPICS.map((tool) => [tool, { purpose: TOOL_MANUALS[tool].purpose }])
4520
+ );
4521
+ return structuredToolResult({
4522
+ schemaVersion: 1,
4523
+ outcome: "completed",
4524
+ resultCode: "help_overview",
4525
+ summary: 'Sakupa tool overview returned. On any failure call help with topic:"diagnose" before retrying, support or report.',
4526
+ data: { tools: catalog, toolOrder: TOOL_TOPICS },
4527
+ nextActions: []
4528
+ });
4529
+ }
4530
+ if (args.topic !== "diagnose") {
4531
+ const manual = TOOL_MANUALS[args.topic];
4532
+ return structuredToolResult({
4533
+ schemaVersion: 1,
4534
+ outcome: "completed",
4535
+ resultCode: "help_tool_manual",
4536
+ summary: `${args.topic}: ${manual.purpose}
4537
+ Side effects: ${manual.sideEffects}
4538
+ Preconditions: ${manual.preconditions}
4539
+ Parameters: ${manual.parameters}
4540
+ Warnings: ${manual.warnings.join(" ")}
4541
+ Next: ${manual.nextStep}`,
4542
+ data: { tool: args.topic, ...manual },
4543
+ nextActions: []
4544
+ });
4545
+ }
4546
+ const diagnosis = await diagnoseProjectBinding(baseCtx);
4547
+ const selected = diagnosis.selectedProjectDir;
4548
+ const marker = selected ? loadProjectMarker(selected) : { kind: "absent" };
4549
+ const site = selected ? loadSiteFile(selected) : { kind: "absent" };
4550
+ let recoveryState = "absent";
4551
+ if (selected) {
4552
+ try {
4553
+ recoveryState = loadRecoveryFile(selected) === null ? "absent" : "ok";
4554
+ } catch {
4555
+ recoveryState = "corrupted";
4556
+ }
4557
+ }
4558
+ const opaqueFailure = args.errorCode === "internal" || args.resultCode === "error_internal";
4559
+ const reportRecommended = opaqueFailure && (diagnosis.diagnosisCode === "project_bound" || diagnosis.diagnosisCode === "roots_request_failed");
4560
+ const helpAuthorization = reportRecommended ? issueReportAuthorization(baseCtx, args.failedTool ?? "unknown") : void 0;
4561
+ const nextActions = reportRecommended ? [
4562
+ {
4563
+ tool: "report",
4564
+ arguments: {
4565
+ toolName: args.failedTool ?? "unknown",
4566
+ helpAuthorization,
4567
+ ...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
4568
+ ...args.requestId !== void 0 ? { requestId: args.requestId } : {}
4569
+ },
4570
+ allowed: true,
4571
+ reasonCode: "help_confirmed_last_resort"
4572
+ }
4573
+ ] : diagnosis.diagnosisCode === "workspace_not_initialized" ? [{ tool: "init", allowed: true, reasonCode: "initialize_active_root" }] : [];
4574
+ const summary = `Help diagnosis: ${diagnosis.diagnosisCode}. ${diagnosis.guidance} MCP version: ${MCP_VERSION}. ` + (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.");
4575
+ return structuredToolResult({
4576
+ schemaVersion: 1,
4577
+ outcome: diagnosis.diagnosisCode === "project_bound" ? "completed" : "blocked",
4578
+ resultCode: `help_${diagnosis.diagnosisCode}`,
4579
+ summary,
4580
+ data: {
4581
+ ...diagnosis,
4582
+ mcpVersion: MCP_VERSION,
4583
+ projectMarkerState: marker.kind,
4584
+ siteState: site.kind,
4585
+ recoveryState,
4586
+ reportRecommended,
4587
+ ...helpAuthorization !== void 0 ? { helpAuthorization } : {},
4588
+ ...args.failedTool !== void 0 ? { failedTool: args.failedTool } : {},
4589
+ ...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
4590
+ ...args.resultCode !== void 0 ? { failedResultCode: args.resultCode } : {}
4591
+ },
4592
+ nextActions
4593
+ });
4594
+ } catch (error) {
4595
+ return toolError(error);
4596
+ }
4597
+ }
4598
+ );
4599
+ }
4600
+
3781
4601
  // src/transport.ts
3782
4602
  var FetchTransport = class {
3783
4603
  baseUrl;
@@ -3876,6 +4696,7 @@ var FetchTransport = class {
3876
4696
  };
3877
4697
 
3878
4698
  // src/server.ts
4699
+ import { resolve as resolve6 } from "node:path";
3879
4700
  var instructionsFor = (hostPattern) => `Sakupa publishes AI-made static websites. AI-made pages, live in seconds.
3880
4701
 
3881
4702
  Workflow:
@@ -3905,14 +4726,17 @@ Workflow:
3905
4726
  24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
3906
4727
  Recovery writes the new local credential before downloading content. If a session stops after
3907
4728
  .sakupa/site.json exists, NEVER repeat DNS recovery: resume with recover action "download".
3908
- 5. support (subscribed sites) opens a support ticket; report sends a
3909
- sanitized diagnostic report after the user explicitly confirms it.
4729
+ 5. If any Sakupa operation is difficult or fails, call help FIRST. support handles billing,
4730
+ payment, refund and other customer-service requests. report is the LAST resort only when
4731
+ help explicitly recommends a product bug report, and submission still requires user review.
3910
4732
 
3911
4733
  Project directory contract: before the first deploy or a new recovery, initialize the intended
3912
- project once by running "npx -y @sakupa/mcp@latest init" with NO path argument from the AI
3913
- tool's current project directory. This immediately creates the non-secret .sakupa/project.json
3914
- there. ONE MCP process = ONE cwd-locked project = ONE site. Site tools do not accept projectDir
3915
- and cannot select another root; plans and public_recovery portal remain project-independent.
4734
+ project by calling init with NO path argument. init uses the IDE's exact MCP Root and creates the
4735
+ non-secret .sakupa/project.json directly there. The CLI command
4736
+ "npx -y @sakupa/mcp@latest init" remains the safe fallback for clients without MCP Roots and
4737
+ also accepts NO path argument. ONE MCP process = ONE Roots-first locked project = ONE site.
4738
+ Site tools do not accept projectDir and cannot select another root; help, plans, report preview
4739
+ and public_recovery portal remain project-independent.
3916
4740
  Sakupa stores .sakupa/site.json and recovery state only in the locked directory; it never uses
3917
4741
  package.json, .git, framework names or output-directory names to guess. For deploy, ALWAYS pass
3918
4742
  outputDir separately as the exact path RELATIVE to the locked directory (use "." when publishing
@@ -3920,14 +4744,17 @@ the root); outputDir is
3920
4744
  required and may have ANY name, so inspect the current project. If it differs from the last
3921
4745
  successful publish directory, show the old and new paths and obtain explicit confirmation
3922
4746
  before retrying with outputDirChangeConfirmed: true.
4747
+ After init, require sakupaAtProjectRoot=true. Before deploy, Sakupa checks every directory segment
4748
+ between the project Root and outputDir for a misplaced .sakupa and safely relocates only validated,
4749
+ non-conflicting state; never copy, delete or overwrite site.json by shell command.
3923
4750
  After every deploy, TELL the user which environment it went to (deploy results carry an
3924
4751
  Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
3925
4752
  refresh and delete echo
3926
- the cwd-locked directory they acted on.
4753
+ the Roots-first locked directory they acted on.
3927
4754
 
3928
- When the same operation fails twice in a row, or the user is clearly stuck or
3929
- frustrated, proactively offer report: it files the problem into Sakupa's ticket and
3930
- alert stream, and you should attach your own factual account via agentContext.
4755
+ On any difficulty, call help before retrying or escalating. Only offer report when help returns
4756
+ reportRecommended:true; attach your own factual account via agentContext and show the exact
4757
+ sanitized preview before asking the user to confirm submission.
3931
4758
 
3932
4759
  Present every step as Sakupa's own: never attribute DNS, certificates or hosting to
3933
4760
  underlying infrastructure vendors in front of the user. Relay DNS record values and full
@@ -3961,14 +4788,32 @@ function createSakupaMcpServer(opts) {
3961
4788
  { name: "sakupa", version: MCP_VERSION },
3962
4789
  { instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)) }
3963
4790
  );
4791
+ const processCwd = resolve6(opts.projectDir ?? process.cwd());
4792
+ const rootsProvider = opts.rootsProvider ?? (async () => {
4793
+ const capabilities = server.server.getClientCapabilities();
4794
+ if (!capabilities?.roots) return { supported: false, roots: [] };
4795
+ try {
4796
+ const response = await server.server.listRoots();
4797
+ return { supported: true, roots: response.roots };
4798
+ } catch (error) {
4799
+ return {
4800
+ supported: true,
4801
+ roots: [],
4802
+ error: error instanceof Error ? error.message : String(error)
4803
+ };
4804
+ }
4805
+ });
3964
4806
  const ctx = {
3965
4807
  client,
3966
4808
  apiBaseUrl: opts.apiBaseUrl,
3967
- projectDir: canonicalProjectDirectory(opts.projectDir ?? process.cwd())
4809
+ projectDir: processCwd,
4810
+ rootsProvider,
4811
+ projectBinding: new ProjectBindingResolver(processCwd, rootsProvider)
3968
4812
  };
3969
4813
  registerTools(server, ctx);
3970
4814
  registerBillingTools(server, ctx);
3971
4815
  registerLifecycleTools(server, ctx);
4816
+ registerHelpTools(server, ctx);
3972
4817
  return server;
3973
4818
  }
3974
4819