@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/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bin.ts
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
5
  import { stdout } from "node:process";
6
6
 
7
7
  // src/project-root.ts
@@ -402,7 +402,7 @@ function isFreeSiteAllowanceNetworkReference(value) {
402
402
  }
403
403
 
404
404
  // ../core/dist/domain/version.js
405
- var SAKUPA_MCP_VERSION = "1.0.0";
405
+ var SAKUPA_MCP_VERSION = "1.2.0";
406
406
 
407
407
  // ../core/dist/domain/errors.js
408
408
  var HTTP_STATUS = {
@@ -738,13 +738,15 @@ function previewHostPatternFor(apiBaseUrl) {
738
738
  function loadMcpRuntimeConfig(env = process.env) {
739
739
  const apiBaseUrl = (env["SAKUPA_API_URL"] ?? env["SAKUPA_API_BASE_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
740
740
  const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
741
+ const projectRoot = env["SAKUPA_PROJECT_ROOT"]?.trim() ?? "";
742
+ const projectRootConfig = projectRoot.length > 0 ? { projectRoot } : {};
741
743
  if (apiBaseUrl === TEST_API_BASE_URL) {
742
744
  if (testAccessToken.length === 0) {
743
745
  throw new Error(
744
746
  "The Sakupa Test API requires SAKUPA_TEST_ACCESS_TOKEN. Anonymous Test access is disabled."
745
747
  );
746
748
  }
747
- return { apiBaseUrl, testAccessToken };
749
+ return { apiBaseUrl, testAccessToken, ...projectRootConfig };
748
750
  }
749
751
  if (apiBaseUrl !== PRODUCTION_API_BASE_URL) {
750
752
  throw new Error(
@@ -756,7 +758,7 @@ function loadMcpRuntimeConfig(env = process.env) {
756
758
  `SAKUPA_TEST_ACCESS_TOKEN may only be used with ${TEST_API_BASE_URL}. Remove it before connecting to any other API.`
757
759
  );
758
760
  }
759
- return { apiBaseUrl };
761
+ return { apiBaseUrl, ...projectRootConfig };
760
762
  }
761
763
  function environmentFor(apiBaseUrl) {
762
764
  if (apiBaseUrl === TEST_API_BASE_URL) return "test";
@@ -765,7 +767,11 @@ function environmentFor(apiBaseUrl) {
765
767
  }
766
768
 
767
769
  // src/server.ts
768
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
770
+ import {
771
+ CLIENT_CAPABILITIES_META_KEY,
772
+ McpServer,
773
+ inputResponse
774
+ } from "@modelcontextprotocol/server";
769
775
 
770
776
  // src/api-client.ts
771
777
  var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
@@ -2946,10 +2952,21 @@ async function resumeCredentialRotation(client, projectDir, site, apiBaseUrl) {
2946
2952
  };
2947
2953
  }
2948
2954
 
2955
+ // src/tools/context.ts
2956
+ import {
2957
+ inputRequired
2958
+ } from "@modelcontextprotocol/server";
2959
+
2949
2960
  // src/project-binding.ts
2950
2961
  import { fileURLToPath } from "node:url";
2951
- import { resolve as resolve4 } from "node:path";
2962
+ import { isAbsolute as isAbsolute4, resolve as resolve4 } from "node:path";
2952
2963
  var MCP_ROOTS_TIMEOUT_MS = 5e3;
2964
+ var McpRootsPending = class extends Error {
2965
+ constructor() {
2966
+ super("MCP Roots must be requested from the client before the project can be bound.");
2967
+ this.name = "McpRootsPending";
2968
+ }
2969
+ };
2953
2970
  var ProjectBindingError = class extends Error {
2954
2971
  diagnostics;
2955
2972
  constructor(diagnostics) {
@@ -2959,18 +2976,19 @@ var ProjectBindingError = class extends Error {
2959
2976
  }
2960
2977
  };
2961
2978
  var ProjectBindingResolver = class {
2962
- constructor(processCwd, rootsProvider, rootsTimeoutMs = MCP_ROOTS_TIMEOUT_MS) {
2979
+ constructor(processCwd, rootsProvider, rootsTimeoutMs = MCP_ROOTS_TIMEOUT_MS, configuredRoot) {
2963
2980
  this.processCwd = processCwd;
2964
2981
  this.rootsProvider = rootsProvider;
2965
2982
  this.rootsTimeoutMs = rootsTimeoutMs;
2983
+ this.configuredRoot = configuredRoot;
2966
2984
  }
2967
2985
  bound;
2968
2986
  boundState;
2969
2987
  resolving;
2970
- async resolve() {
2988
+ async resolve(call) {
2971
2989
  if (this.bound) return this.bound;
2972
2990
  if (this.resolving) return this.resolving;
2973
- this.resolving = this.inspect().then((inspection) => {
2991
+ this.resolving = this.inspect(false, call).then((inspection) => {
2974
2992
  if (!inspection.selected) throw new ProjectBindingError(inspection.diagnostics);
2975
2993
  this.bound = inspection.selected;
2976
2994
  this.boundState = inspection.diagnostics;
@@ -2980,18 +2998,18 @@ var ProjectBindingResolver = class {
2980
2998
  });
2981
2999
  return this.resolving;
2982
3000
  }
2983
- async diagnose() {
3001
+ async diagnose(call) {
2984
3002
  if (this.bound) return this.boundState ?? boundDiagnostics(this.processCwd, this.bound);
2985
- const inspection = await this.inspect();
3003
+ const inspection = await this.inspect(false, call);
2986
3004
  if (inspection.selected) {
2987
3005
  this.bound = inspection.selected;
2988
3006
  this.boundState = inspection.diagnostics;
2989
3007
  }
2990
3008
  return inspection.diagnostics;
2991
3009
  }
2992
- async initialize() {
3010
+ async initialize(call) {
2993
3011
  if (this.bound) return this.bound;
2994
- const inspection = await this.inspect(true);
3012
+ const inspection = await this.inspect(true, call);
2995
3013
  if (inspection.selected) {
2996
3014
  this.bound = inspection.selected;
2997
3015
  this.boundState = inspection.diagnostics;
@@ -3001,28 +3019,41 @@ var ProjectBindingResolver = class {
3001
3019
  throw new ProjectBindingError(inspection.diagnostics);
3002
3020
  }
3003
3021
  const initialized = initializeProject(inspection.initializableRoot);
3004
- this.bound = { ...initialized, bindingSource: "mcp_root" };
3022
+ this.bound = {
3023
+ ...initialized,
3024
+ bindingSource: inspection.initializableSource ?? "mcp_root"
3025
+ };
3005
3026
  this.boundState = boundDiagnostics(
3006
3027
  this.processCwd,
3007
3028
  this.bound,
3008
- { supported: true, roots: [] },
3009
- inspection.diagnostics.rootCandidates
3029
+ { supported: inspection.diagnostics.mcpRootsSupported, roots: [] },
3030
+ inspection.diagnostics.rootCandidates,
3031
+ inspection.diagnostics.configuredProjectRoot
3010
3032
  );
3011
3033
  return this.bound;
3012
3034
  }
3013
- async inspect(forInitialization = false) {
3014
- const snapshot = await safeRootsSnapshot(this.rootsProvider, this.rootsTimeoutMs);
3035
+ async inspect(forInitialization = false, call) {
3036
+ const snapshot = await safeRootsSnapshot(this.rootsProvider, this.rootsTimeoutMs, call);
3015
3037
  const rootCandidates = snapshot.roots.map(inspectRoot);
3038
+ const configured = this.configuredRoot === void 0 ? void 0 : inspectConfiguredRoot(this.configuredRoot);
3039
+ const diagnose = (code, guidance) => diagnostic(code, snapshot, this.processCwd, rootCandidates, guidance, configured);
3040
+ const bound = (selected) => ({
3041
+ selected,
3042
+ diagnostics: boundDiagnostics(
3043
+ this.processCwd,
3044
+ selected,
3045
+ snapshot,
3046
+ rootCandidates,
3047
+ configured
3048
+ )
3049
+ });
3016
3050
  const initializedRoots = rootCandidates.filter(
3017
3051
  (candidate) => candidate.initialized && candidate.path !== void 0
3018
3052
  );
3019
3053
  if (snapshot.supported && snapshot.error) {
3020
3054
  return {
3021
- diagnostics: diagnostic(
3055
+ diagnostics: diagnose(
3022
3056
  "roots_request_failed",
3023
- snapshot,
3024
- this.processCwd,
3025
- rootCandidates,
3026
3057
  "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."
3027
3058
  )
3028
3059
  };
@@ -3031,19 +3062,12 @@ var ProjectBindingResolver = class {
3031
3062
  const initializedRoot = initializedRoots[0];
3032
3063
  if (!initializedRoot) throw new Error("initialized Root disappeared during resolution");
3033
3064
  const project = resolveLockedProjectRoot(initializedRoot.path);
3034
- const selected = { ...project, bindingSource: "mcp_root" };
3035
- return {
3036
- selected,
3037
- diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
3038
- };
3065
+ return bound({ ...project, bindingSource: "mcp_root" });
3039
3066
  }
3040
3067
  if (initializedRoots.length > 1) {
3041
3068
  return {
3042
- diagnostics: diagnostic(
3069
+ diagnostics: diagnose(
3043
3070
  "multiple_initialized_roots",
3044
- snapshot,
3045
- this.processCwd,
3046
- rootCandidates,
3047
3071
  "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."
3048
3072
  )
3049
3073
  };
@@ -3057,22 +3081,17 @@ var ProjectBindingResolver = class {
3057
3081
  if (!validRoot) throw new Error("workspace Root disappeared during initialization");
3058
3082
  return {
3059
3083
  initializableRoot: validRoot.path,
3060
- diagnostics: diagnostic(
3084
+ initializableSource: "mcp_root",
3085
+ diagnostics: diagnose(
3061
3086
  "workspace_not_initialized",
3062
- snapshot,
3063
- this.processCwd,
3064
- rootCandidates,
3065
3087
  `The active MCP workspace ${validRoot.path} is ready to initialize.`
3066
3088
  )
3067
3089
  };
3068
3090
  }
3069
3091
  if (validRoots.length > 1) {
3070
3092
  return {
3071
- diagnostics: diagnostic(
3093
+ diagnostics: diagnose(
3072
3094
  "multiple_uninitialized_roots",
3073
- snapshot,
3074
- this.processCwd,
3075
- rootCandidates,
3076
3095
  "The IDE exposes multiple uninitialized workspace Roots. Open only the intended project before calling init; Sakupa will not choose a directory for the user."
3077
3096
  )
3078
3097
  };
@@ -3082,54 +3101,59 @@ var ProjectBindingResolver = class {
3082
3101
  const validRoot = validRoots[0];
3083
3102
  if (!validRoot) throw new Error("workspace Root disappeared during diagnosis");
3084
3103
  return {
3085
- diagnostics: diagnostic(
3104
+ diagnostics: diagnose(
3086
3105
  "workspace_not_initialized",
3087
- snapshot,
3088
- this.processCwd,
3089
- rootCandidates,
3090
3106
  `The IDE workspace ${validRoot.path} is not initialized. Call init with no path arguments; it will create .sakupa directly in that workspace Root.`
3091
3107
  )
3092
3108
  };
3093
3109
  }
3094
3110
  if (snapshot.supported && validRoots.length > 1) {
3095
3111
  return {
3096
- diagnostics: diagnostic(
3112
+ diagnostics: diagnose(
3097
3113
  "multiple_uninitialized_roots",
3098
- snapshot,
3099
- this.processCwd,
3100
- rootCandidates,
3101
3114
  "The IDE exposes multiple uninitialized workspace Roots. Open only the intended project, then call init. Sakupa will not guess a project directory."
3102
3115
  )
3103
3116
  };
3104
3117
  }
3118
+ if (configured) {
3119
+ if (configured.problem !== void 0 || configured.path === void 0) {
3120
+ return {
3121
+ diagnostics: diagnose(
3122
+ "invalid_configured_root",
3123
+ `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.`
3124
+ )
3125
+ };
3126
+ }
3127
+ if (configured.initialized) {
3128
+ const project = resolveLockedProjectRoot(configured.path);
3129
+ return bound({ ...project, bindingSource: "configured_root" });
3130
+ }
3131
+ return {
3132
+ ...forInitialization ? { initializableRoot: configured.path, initializableSource: "configured_root" } : {},
3133
+ diagnostics: diagnose(
3134
+ "workspace_not_initialized",
3135
+ `The configured project root ${configured.path} (SAKUPA_PROJECT_ROOT) is not initialized. Call init with no path arguments; it will create .sakupa directly there.`
3136
+ )
3137
+ };
3138
+ }
3105
3139
  if (snapshot.supported) {
3106
3140
  return {
3107
- diagnostics: diagnostic(
3141
+ diagnostics: diagnose(
3108
3142
  "workspace_not_initialized",
3109
- snapshot,
3110
- this.processCwd,
3111
- rootCandidates,
3112
3143
  "The IDE did not expose one usable file workspace Root. Open exactly one local project workspace, then retry help before calling init or deploy."
3113
3144
  )
3114
3145
  };
3115
3146
  }
3116
3147
  try {
3117
3148
  const cwdProject = resolveLockedProjectRoot(this.processCwd);
3118
- const selected = { ...cwdProject, bindingSource: "process_cwd" };
3119
- return {
3120
- selected,
3121
- diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
3122
- };
3149
+ return bound({ ...cwdProject, bindingSource: "process_cwd" });
3123
3150
  } catch {
3124
3151
  }
3125
3152
  const cwdProblem = inspectDirectory(this.processCwd);
3126
3153
  return {
3127
- diagnostics: diagnostic(
3154
+ diagnostics: diagnose(
3128
3155
  cwdProblem.problem ? "invalid_process_cwd" : "process_cwd_is_not_workspace",
3129
- snapshot,
3130
- this.processCwd,
3131
- rootCandidates,
3132
- "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."
3156
+ "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."
3133
3157
  )
3134
3158
  };
3135
3159
  }
@@ -3139,11 +3163,12 @@ function fileRootUriToPath(uri, windows = process.platform === "win32") {
3139
3163
  if (parsed.protocol !== "file:") throw new Error("Root URI is not a file URI");
3140
3164
  return fileURLToPath(parsed, { windows });
3141
3165
  }
3142
- async function safeRootsSnapshot(provider, timeoutMs = MCP_ROOTS_TIMEOUT_MS) {
3166
+ async function safeRootsSnapshot(provider, timeoutMs = MCP_ROOTS_TIMEOUT_MS, call) {
3143
3167
  if (!provider) return { supported: false, roots: [] };
3144
3168
  try {
3145
- return await withOperationTimeout("MCP Roots request", timeoutMs, () => provider());
3169
+ return await withOperationTimeout("MCP Roots request", timeoutMs, () => provider(call));
3146
3170
  } catch (error) {
3171
+ if (error instanceof McpRootsPending) throw error;
3147
3172
  return {
3148
3173
  supported: true,
3149
3174
  roots: [],
@@ -3171,6 +3196,31 @@ function inspectRoot(root) {
3171
3196
  };
3172
3197
  }
3173
3198
  }
3199
+ function inspectConfiguredRoot(configured) {
3200
+ if (!isAbsolute4(configured)) {
3201
+ return {
3202
+ configured,
3203
+ initialized: false,
3204
+ problem: "the value must be an absolute path"
3205
+ };
3206
+ }
3207
+ try {
3208
+ const path = canonicalProjectDirectory(configured);
3209
+ const marker = loadProjectMarker(path);
3210
+ return {
3211
+ configured,
3212
+ path,
3213
+ initialized: marker.kind === "ok",
3214
+ ...marker.kind === "corrupted" ? { problem: marker.problem } : {}
3215
+ };
3216
+ } catch (error) {
3217
+ return {
3218
+ configured,
3219
+ initialized: false,
3220
+ problem: error instanceof Error ? error.message : String(error)
3221
+ };
3222
+ }
3223
+ }
3174
3224
  function inspectDirectory(path) {
3175
3225
  try {
3176
3226
  return { path: canonicalProjectDirectory(resolve4(path)) };
@@ -3178,22 +3228,24 @@ function inspectDirectory(path) {
3178
3228
  return { problem: error instanceof Error ? error.message : String(error) };
3179
3229
  }
3180
3230
  }
3181
- function diagnostic(diagnosisCode, snapshot, processCwd, rootCandidates, guidance) {
3231
+ function diagnostic(diagnosisCode, snapshot, processCwd, rootCandidates, guidance, configured) {
3182
3232
  return {
3183
3233
  diagnosisCode,
3184
3234
  mcpRootsSupported: snapshot.supported,
3185
3235
  processCwd,
3186
3236
  rootCandidates,
3237
+ ...configured ? { configuredProjectRoot: configured } : {},
3187
3238
  guidance,
3188
3239
  reportRecommended: false
3189
3240
  };
3190
3241
  }
3191
- function boundDiagnostics(processCwd, selected, snapshot = { supported: false, roots: [] }, rootCandidates = []) {
3242
+ function boundDiagnostics(processCwd, selected, snapshot = { supported: false, roots: [] }, rootCandidates = [], configured) {
3192
3243
  return {
3193
3244
  diagnosisCode: "project_bound",
3194
3245
  mcpRootsSupported: snapshot.supported,
3195
3246
  processCwd,
3196
3247
  rootCandidates,
3248
+ ...configured ? { configuredProjectRoot: configured } : {},
3197
3249
  selectedProjectDir: selected.projectDir,
3198
3250
  bindingSource: selected.bindingSource,
3199
3251
  guidance: `Sakupa is locked to ${selected.projectDir} from ${selected.bindingSource}.`,
@@ -3221,7 +3273,7 @@ var TARGET_MCP_TOOL_NAMES = [
3221
3273
  "support",
3222
3274
  "report"
3223
3275
  ];
3224
- var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
3276
+ var STRUCTURED_TOOL_OUTPUT_SCHEMA = z.object({
3225
3277
  schemaVersion: z.literal(1),
3226
3278
  outcome: z.enum([
3227
3279
  "completed",
@@ -3303,7 +3355,7 @@ var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
3303
3355
  reasonCode: z.string().optional()
3304
3356
  })
3305
3357
  )
3306
- };
3358
+ });
3307
3359
  function structuredToolResult(envelope) {
3308
3360
  const clientTimeZone = clientRuntimeTimeZone();
3309
3361
  const presentation = {
@@ -3412,14 +3464,19 @@ function resolverFor(ctx) {
3412
3464
  if (ctx.projectBinding) return ctx.projectBinding;
3413
3465
  let resolver = fallbackResolvers.get(ctx);
3414
3466
  if (!resolver) {
3415
- resolver = new ProjectBindingResolver(ctx.projectDir, ctx.rootsProvider);
3467
+ resolver = new ProjectBindingResolver(
3468
+ ctx.projectDir,
3469
+ ctx.rootsProvider,
3470
+ MCP_ROOTS_TIMEOUT_MS,
3471
+ ctx.configuredProjectRoot
3472
+ );
3416
3473
  fallbackResolvers.set(ctx, resolver);
3417
3474
  }
3418
3475
  return resolver;
3419
3476
  }
3420
- async function withProjectDir(ctx) {
3477
+ async function withProjectDir(ctx, call) {
3421
3478
  try {
3422
- const binding = await resolverFor(ctx).resolve();
3479
+ const binding = await resolverFor(ctx).resolve(call);
3423
3480
  const resolved = resolveLockedProjectRoot(binding.projectDir);
3424
3481
  return {
3425
3482
  ...ctx,
@@ -3442,12 +3499,12 @@ async function withProjectDir(ctx) {
3442
3499
  throw error;
3443
3500
  }
3444
3501
  }
3445
- async function diagnoseProjectBinding(ctx) {
3446
- return resolverFor(ctx).diagnose();
3502
+ async function diagnoseProjectBinding(ctx, call) {
3503
+ return resolverFor(ctx).diagnose(call);
3447
3504
  }
3448
- async function initializeWorkspaceProject(ctx) {
3505
+ async function initializeWorkspaceProject(ctx, call) {
3449
3506
  try {
3450
- const resolved = await resolverFor(ctx).initialize();
3507
+ const resolved = await resolverFor(ctx).initialize(call);
3451
3508
  return {
3452
3509
  ...ctx,
3453
3510
  projectDir: resolved.projectDir,
@@ -3463,10 +3520,11 @@ async function initializeWorkspaceProject(ctx) {
3463
3520
  throw error;
3464
3521
  }
3465
3522
  }
3466
- async function optionalProjectContext(ctx) {
3523
+ async function optionalProjectContext(ctx, call) {
3467
3524
  try {
3468
- return await withProjectDir(ctx);
3469
- } catch {
3525
+ return await withProjectDir(ctx, call);
3526
+ } catch (error) {
3527
+ if (error instanceof McpRootsPending) throw error;
3470
3528
  return null;
3471
3529
  }
3472
3530
  }
@@ -3514,6 +3572,9 @@ function requireSiteFile(ctx) {
3514
3572
  }
3515
3573
  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.";
3516
3574
  function toolError(e) {
3575
+ if (e instanceof McpRootsPending) {
3576
+ return inputRequired({ inputRequests: { roots: inputRequired.listRoots() } });
3577
+ }
3517
3578
  const isSakupa = isSakupaError(e);
3518
3579
  const errorCode = isSakupa ? e.code : "internal";
3519
3580
  const rawDetails = isSakupaError(e) && e.details && typeof e.details === "object" ? e.details : void 0;
@@ -4030,13 +4091,13 @@ function registerTools(server, baseCtx) {
4030
4091
  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.",
4031
4092
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4032
4093
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
4033
- inputSchema: {
4094
+ inputSchema: z2.object({
4034
4095
  outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection).")
4035
- }
4096
+ })
4036
4097
  },
4037
- async (args) => {
4098
+ async (args, call) => {
4038
4099
  try {
4039
- const ctx = await withProjectDir(baseCtx);
4100
+ const ctx = await withProjectDir(baseCtx, call);
4040
4101
  const analysis = await analyzeProject(ctx.projectDir, {
4041
4102
  ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
4042
4103
  });
@@ -4057,7 +4118,7 @@ Next action: ${analysis.suggestedNextAction}`,
4057
4118
  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.`,
4058
4119
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4059
4120
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
4060
- inputSchema: {
4121
+ inputSchema: z2.object({
4061
4122
  outputDir: z2.string().min(1).describe(
4062
4123
  '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.'
4063
4124
  ),
@@ -4083,12 +4144,12 @@ Next action: ${analysis.suggestedNextAction}`,
4083
4144
  "Deprecated compatibility field. Project independence is established only by `sakupa-mcp init`, never inferred from package.json or folder names."
4084
4145
  ),
4085
4146
  lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
4086
- }
4147
+ })
4087
4148
  },
4088
- async (args) => {
4149
+ async (args, call) => {
4089
4150
  let releaseHandoffLock;
4090
4151
  try {
4091
- const ctx = await withProjectDir(baseCtx);
4152
+ const ctx = await withProjectDir(baseCtx, call);
4092
4153
  const analysis = await analyzeProject(ctx.projectDir, { outputDir: args.outputDir });
4093
4154
  if (!analysis.deployable || !analysis.files) {
4094
4155
  return notDeployableResult(analysis);
@@ -4713,11 +4774,11 @@ Optional security recommendation: this management credential was created at ${ti
4713
4774
  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.",
4714
4775
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4715
4776
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
4716
- inputSchema: {}
4777
+ inputSchema: z2.object({})
4717
4778
  },
4718
- async () => {
4779
+ async (_args, call) => {
4719
4780
  try {
4720
- const ctx = await withProjectDir(baseCtx);
4781
+ const ctx = await withProjectDir(baseCtx, call);
4721
4782
  const site = requireSiteFile(ctx);
4722
4783
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
4723
4784
  if (site.url) {
@@ -4746,11 +4807,11 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4746
4807
  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.",
4747
4808
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4748
4809
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
4749
- inputSchema: {}
4810
+ inputSchema: z2.object({})
4750
4811
  },
4751
- async () => {
4812
+ async (_args, call) => {
4752
4813
  try {
4753
- const ctx = await withProjectDir(baseCtx);
4814
+ const ctx = await withProjectDir(baseCtx, call);
4754
4815
  const site = requireSiteFile(ctx);
4755
4816
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
4756
4817
  noteSiteMode(res.siteId, res.mode);
@@ -4777,15 +4838,15 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4777
4838
  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.`,
4778
4839
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4779
4840
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
4780
- inputSchema: {
4841
+ inputSchema: z2.object({
4781
4842
  plan: planEnum.describe(
4782
4843
  "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
4783
4844
  )
4784
- }
4845
+ })
4785
4846
  },
4786
- async (args) => {
4847
+ async (args, call) => {
4787
4848
  try {
4788
- const ctx = await withProjectDir(baseCtx);
4849
+ const ctx = await withProjectDir(baseCtx, call);
4789
4850
  const site = requireSiteFile(ctx);
4790
4851
  const res = await ctx.client.createPlanCheckout(
4791
4852
  {
@@ -4823,17 +4884,17 @@ Once Stripe confirms payment and Sakupa synchronizes the subscription, the curre
4823
4884
  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.`,
4824
4885
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4825
4886
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
4826
- inputSchema: {
4887
+ inputSchema: z2.object({
4827
4888
  action: z2.enum(["start", "status"]),
4828
4889
  hostname: z2.string().optional().describe("Required for start."),
4829
4890
  verificationId: z2.string().optional().describe(
4830
4891
  "Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
4831
4892
  )
4832
- }
4893
+ })
4833
4894
  },
4834
- async (args) => {
4895
+ async (args, call) => {
4835
4896
  try {
4836
- const ctx = await withProjectDir(baseCtx);
4897
+ const ctx = await withProjectDir(baseCtx, call);
4837
4898
  const site = requireSiteFile(ctx);
4838
4899
  if (args.action === "status") {
4839
4900
  const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
@@ -4962,11 +5023,11 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
4962
5023
  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).",
4963
5024
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4964
5025
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
4965
- inputSchema: {}
5026
+ inputSchema: z2.object({})
4966
5027
  },
4967
- async () => {
5028
+ async (_args, call) => {
4968
5029
  try {
4969
- const ctx = await withProjectDir(baseCtx);
5030
+ const ctx = await withProjectDir(baseCtx, call);
4970
5031
  const site = requireSiteFile(ctx);
4971
5032
  const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
4972
5033
  noteSiteMode(res.siteId, res.mode);
@@ -5006,14 +5067,14 @@ Full status:`, res);
5006
5067
  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.",
5007
5068
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5008
5069
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
5009
- inputSchema: {
5070
+ inputSchema: z2.object({
5010
5071
  scope: z2.enum(["site", "public_recovery"])
5011
- }
5072
+ })
5012
5073
  },
5013
- async (args) => {
5074
+ async (args, call) => {
5014
5075
  try {
5015
5076
  if (args.scope === "site") {
5016
- const ctx = await withProjectDir(baseCtx);
5077
+ const ctx = await withProjectDir(baseCtx, call);
5017
5078
  const site = requireSiteFile(ctx);
5018
5079
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
5019
5080
  return structuredToolResult({
@@ -5062,7 +5123,7 @@ Full status:`, res);
5062
5123
  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.",
5063
5124
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5064
5125
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
5065
- inputSchema: {
5126
+ inputSchema: z2.object({
5066
5127
  action: z2.enum(["start", "status", "complete", "download"]),
5067
5128
  hostname: z2.string().optional().describe("Required for start."),
5068
5129
  verificationId: z2.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
@@ -5070,11 +5131,11 @@ Full status:`, res);
5070
5131
  "REQUIRED for complete/download: exact extraction directory relative to the initialized project root. Inspect the current project; Sakupa never guesses a name."
5071
5132
  ),
5072
5133
  preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
5073
- }
5134
+ })
5074
5135
  },
5075
- async (args) => {
5136
+ async (args, call) => {
5076
5137
  try {
5077
- const ctx = await withProjectDir(baseCtx);
5138
+ const ctx = await withProjectDir(baseCtx, call);
5078
5139
  if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
5079
5140
  throw new LocalGuidanceError(
5080
5141
  "invalid_request",
@@ -5423,16 +5484,16 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5423
5484
  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.",
5424
5485
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5425
5486
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
5426
- inputSchema: {
5487
+ inputSchema: z2.object({
5427
5488
  category: ticketCategoryEnum,
5428
5489
  subject: z2.string().describe("Short subject line."),
5429
5490
  description: z2.string().describe("Problem description (no secrets, no card data)."),
5430
5491
  contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
5431
- }
5492
+ })
5432
5493
  },
5433
- async (args) => {
5494
+ async (args, call) => {
5434
5495
  try {
5435
- const ctx = await withProjectDir(baseCtx);
5496
+ const ctx = await withProjectDir(baseCtx, call);
5436
5497
  const site = requireSiteFile(ctx);
5437
5498
  const res = await ctx.client.createTicket(site.credential, {
5438
5499
  siteId: site.siteId,
@@ -5457,7 +5518,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5457
5518
  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.",
5458
5519
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5459
5520
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
5460
- inputSchema: {
5521
+ inputSchema: z2.object({
5461
5522
  toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy".'),
5462
5523
  helpAuthorization: z2.string().describe("Short-lived authorization returned only by help when report is recommended."),
5463
5524
  errorCode: z2.string().optional(),
@@ -5473,12 +5534,12 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5473
5534
  "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."
5474
5535
  ),
5475
5536
  confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
5476
- }
5537
+ })
5477
5538
  },
5478
- async (args) => {
5539
+ async (args, call) => {
5479
5540
  try {
5480
5541
  requireReportAuthorization(baseCtx, args.helpAuthorization, args.toolName);
5481
- const ctx = await optionalProjectContext(baseCtx);
5542
+ const ctx = await optionalProjectContext(baseCtx, call);
5482
5543
  const siteState = ctx ? loadSiteFile(ctx.projectDir) : { kind: "absent" };
5483
5544
  const site = siteState.kind === "ok" ? siteState.file : null;
5484
5545
  const diagnostics = {
@@ -5558,11 +5619,11 @@ function registerBillingTools(server, baseCtx) {
5558
5619
  "plans",
5559
5620
  {
5560
5621
  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.",
5561
- inputSchema: {},
5622
+ inputSchema: z3.object({}),
5562
5623
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5563
5624
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
5564
5625
  },
5565
- async () => {
5626
+ async (_args, call) => {
5566
5627
  try {
5567
5628
  const catalog = await baseCtx.client.getBillingPlanCatalog();
5568
5629
  return structuredToolResult({
@@ -5582,15 +5643,15 @@ function registerBillingTools(server, baseCtx) {
5582
5643
  "change",
5583
5644
  {
5584
5645
  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.",
5585
- inputSchema: {
5646
+ inputSchema: z3.object({
5586
5647
  operationId: z3.string().min(1)
5587
- },
5648
+ }),
5588
5649
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5589
5650
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
5590
5651
  },
5591
- async (args) => {
5652
+ async (args, call) => {
5592
5653
  try {
5593
- const ctx = await withProjectDir(baseCtx);
5654
+ const ctx = await withProjectDir(baseCtx, call);
5594
5655
  const site = requireSiteFile(ctx);
5595
5656
  const result = await ctx.client.changeSubscriptionPlan(site.credential, {
5596
5657
  siteId: site.siteId,
@@ -5674,7 +5735,7 @@ var TOOL_MANUALS = {
5674
5735
  init: {
5675
5736
  purpose: "Initialize the active IDE workspace as one Sakupa project.",
5676
5737
  sideEffects: "Creates only .sakupa/project.json locally; no API call, site or charge.",
5677
- preconditions: "Exactly one usable MCP workspace Root. If Roots are unavailable, help may authorize the AI to use CLI init.",
5738
+ 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.",
5678
5739
  parameterNames: [],
5679
5740
  parameters: "No parameters and no path argument.",
5680
5741
  warnings: [
@@ -5873,13 +5934,13 @@ function registerHelpTools(server, baseCtx) {
5873
5934
  "init",
5874
5935
  {
5875
5936
  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.",
5876
- inputSchema: {},
5937
+ inputSchema: z4.object({}),
5877
5938
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5878
5939
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
5879
5940
  },
5880
- async () => {
5941
+ async (_args, call) => {
5881
5942
  try {
5882
- const ctx = await initializeWorkspaceProject(baseCtx);
5943
+ const ctx = await initializeWorkspaceProject(baseCtx, call);
5883
5944
  const marker = loadProjectMarker(ctx.projectDir);
5884
5945
  if (marker.kind !== "ok")
5885
5946
  throw new Error("init postcondition failed: project marker missing");
@@ -5913,17 +5974,17 @@ function registerHelpTools(server, baseCtx) {
5913
5974
  "help",
5914
5975
  {
5915
5976
  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.",
5916
- inputSchema: {
5977
+ inputSchema: z4.object({
5917
5978
  topic: z4.enum(HELP_TOPICS).optional().default("diagnose"),
5918
5979
  failedTool: z4.string().optional(),
5919
5980
  errorCode: z4.string().optional(),
5920
5981
  resultCode: z4.string().optional(),
5921
5982
  requestId: z4.string().optional()
5922
- },
5983
+ }),
5923
5984
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5924
5985
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
5925
5986
  },
5926
- async (args) => {
5987
+ async (args, call) => {
5927
5988
  try {
5928
5989
  if (args.topic === "overview") {
5929
5990
  const catalog = Object.fromEntries(
@@ -5985,7 +6046,7 @@ Terminology: ${terminologyText}` : ""),
5985
6046
  nextActions: []
5986
6047
  });
5987
6048
  }
5988
- const diagnosis = await diagnoseProjectBinding(baseCtx);
6049
+ const diagnosis = await diagnoseProjectBinding(baseCtx, call);
5989
6050
  const selected = diagnosis.selectedProjectDir;
5990
6051
  const marker = selected ? loadProjectMarker(selected) : { kind: "absent" };
5991
6052
  const site = selected ? loadSiteFile(selected) : { kind: "absent" };
@@ -6064,18 +6125,18 @@ function registerCredentialTools(server, baseCtx) {
6064
6125
  "rotate",
6065
6126
  {
6066
6127
  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.",
6067
- inputSchema: {
6128
+ inputSchema: z5.object({
6068
6129
  confirmed: z5.boolean().optional().describe(
6069
6130
  "True only after showing the rotate preview and the user explicitly approves revoking every old credential."
6070
6131
  )
6071
- },
6132
+ }),
6072
6133
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
6073
6134
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
6074
6135
  },
6075
- async (args) => {
6136
+ async (args, call) => {
6076
6137
  let releaseLock;
6077
6138
  try {
6078
- const ctx = await withProjectDir(baseCtx);
6139
+ const ctx = await withProjectDir(baseCtx, call);
6079
6140
  let site = requireSiteFile(ctx);
6080
6141
  const pending = loadCredentialRotation(ctx.projectDir);
6081
6142
  if (pending.kind !== "absent" || args.confirmed === true) {
@@ -6382,11 +6443,13 @@ language. Keep option IDs, tool names, exact arguments, URLs, field names and co
6382
6443
  unchanged.
6383
6444
 
6384
6445
  Project directory contract: before the first deploy or a new recovery, CALL the init MCP tool with
6385
- NO path argument. init uses the IDE's exact MCP Root and creates the non-secret
6446
+ NO path argument. init uses the IDE's exact MCP Root \u2014 or, when the client provides no Roots, the
6447
+ SAKUPA_PROJECT_ROOT directory configured for this MCP server \u2014 and creates the non-secret
6386
6448
  .sakupa/project.json directly there. Do not merely print installation or CLI instructions when the
6387
- init tool is available. Only after help confirms that the client does not provide MCP Roots may the
6388
- AI itself use the CLI command "npx -y @sakupa/mcp@latest init" as a fallback; never ask the user to
6389
- run it. The CLI also accepts NO path argument. ONE MCP process = ONE Roots-first locked project = ONE site.
6449
+ init tool is available. Only after help confirms that the client provides neither MCP Roots nor
6450
+ SAKUPA_PROJECT_ROOT may the AI itself use the CLI command "npx -y @sakupa/mcp@latest init" as a
6451
+ fallback; never ask the user to run it. The CLI also accepts NO path argument. ONE MCP process = ONE
6452
+ locked project = ONE site.
6390
6453
  Site tools do not accept projectDir and cannot select another root; help, plans and report preview
6391
6454
  and public_recovery portal remain project-independent.
6392
6455
  Sakupa stores .sakupa/site.json and recovery state only in the locked directory; it never uses
@@ -6466,29 +6529,19 @@ function createSakupaMcpServer(opts) {
6466
6529
  { instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)) }
6467
6530
  );
6468
6531
  const processCwd = resolve6(opts.projectDir ?? process.cwd());
6469
- const rootsProvider = opts.rootsProvider ?? (async () => {
6470
- const capabilities = server.server.getClientCapabilities();
6471
- if (!capabilities?.roots) return { supported: false, roots: [] };
6472
- try {
6473
- const response = await server.server.listRoots(void 0, {
6474
- timeout: MCP_ROOTS_TIMEOUT_MS,
6475
- maxTotalTimeout: MCP_ROOTS_TIMEOUT_MS
6476
- });
6477
- return { supported: true, roots: response.roots };
6478
- } catch (error) {
6479
- return {
6480
- supported: true,
6481
- roots: [],
6482
- error: error instanceof Error ? error.message : String(error)
6483
- };
6484
- }
6485
- });
6532
+ const rootsProvider = opts.rootsProvider ?? ((call) => readClientRoots(server, call));
6486
6533
  const ctx = {
6487
6534
  client,
6488
6535
  apiBaseUrl: opts.apiBaseUrl,
6489
6536
  projectDir: processCwd,
6490
6537
  rootsProvider,
6491
- projectBinding: new ProjectBindingResolver(processCwd, rootsProvider)
6538
+ ...opts.projectRoot !== void 0 ? { configuredProjectRoot: opts.projectRoot } : {},
6539
+ projectBinding: new ProjectBindingResolver(
6540
+ processCwd,
6541
+ rootsProvider,
6542
+ MCP_ROOTS_TIMEOUT_MS,
6543
+ opts.projectRoot
6544
+ )
6492
6545
  };
6493
6546
  registerTools(server, ctx);
6494
6547
  registerBillingTools(server, ctx);
@@ -6496,6 +6549,38 @@ function createSakupaMcpServer(opts) {
6496
6549
  registerHelpTools(server, ctx);
6497
6550
  return server;
6498
6551
  }
6552
+ async function readClientRoots(server, call) {
6553
+ if (call?.mcpReq.envelope !== void 0) {
6554
+ const envelope = call.mcpReq.envelope;
6555
+ const declared = envelope[CLIENT_CAPABILITIES_META_KEY];
6556
+ if (!declared?.roots) return { supported: false, roots: [] };
6557
+ const answered = inputResponse(call.mcpReq.inputResponses, "roots");
6558
+ if (answered.kind === "roots") return { supported: true, roots: answered.roots };
6559
+ if (call.mcpReq.inputResponses !== void 0) {
6560
+ return {
6561
+ supported: true,
6562
+ roots: [],
6563
+ error: "The client retried without answering the embedded roots/list request."
6564
+ };
6565
+ }
6566
+ throw new McpRootsPending();
6567
+ }
6568
+ const capabilities = server.server.getClientCapabilities();
6569
+ if (!capabilities?.roots) return { supported: false, roots: [] };
6570
+ try {
6571
+ const response = await server.server.listRoots(void 0, {
6572
+ timeout: MCP_ROOTS_TIMEOUT_MS,
6573
+ maxTotalTimeout: MCP_ROOTS_TIMEOUT_MS
6574
+ });
6575
+ return { supported: true, roots: response.roots };
6576
+ } catch (error) {
6577
+ return {
6578
+ supported: true,
6579
+ roots: [],
6580
+ error: error instanceof Error ? error.message : String(error)
6581
+ };
6582
+ }
6583
+ }
6499
6584
 
6500
6585
  // src/bin.ts
6501
6586
  async function main() {
@@ -6508,15 +6593,20 @@ async function main() {
6508
6593
  process.exitCode = result.exitCode;
6509
6594
  return;
6510
6595
  }
6596
+ if (argv[0] === "--version" || argv[0] === "-v") {
6597
+ stdout.write(`${MCP_VERSION}
6598
+ `);
6599
+ return;
6600
+ }
6511
6601
  if (argv.length > 0) {
6512
- throw new Error("Usage: sakupa-mcp [init]");
6602
+ throw new Error("Usage: sakupa-mcp [init | --version]");
6513
6603
  }
6514
6604
  const config = loadMcpRuntimeConfig();
6515
- const server = createSakupaMcpServer(config);
6516
- const transport = new StdioServerTransport();
6517
- await server.connect(transport);
6605
+ serveStdio(() => createSakupaMcpServer(config), {
6606
+ onerror: (error) => console.error("[sakupa-mcp] transport error:", error.message)
6607
+ });
6518
6608
  console.error(
6519
- `[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}; process cwd fallback: ${process.cwd()}; MCP Roots preferred)`
6609
+ `[sakupa-mcp] v${MCP_VERSION} serving stdio (api: ${config.apiBaseUrl}; configured root: ${config.projectRoot ?? "none"}; process cwd fallback: ${process.cwd()}; MCP Roots preferred)`
6520
6610
  );
6521
6611
  }
6522
6612
  main().catch((err2) => {