@sakupa/mcp 0.7.44 → 0.7.46

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 +540 -114
  2. package/dist/index.js +538 -112
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -125,7 +125,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
125
125
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
126
126
 
127
127
  // ../core/dist/domain/version.js
128
- var SAKUPA_MCP_VERSION = "0.7.44";
128
+ var SAKUPA_MCP_VERSION = "0.7.46";
129
129
 
130
130
  // ../core/dist/domain/errors.js
131
131
  var HTTP_STATUS = {
@@ -736,8 +736,8 @@ var HttpApiClient = class {
736
736
  body: body?.slice(0, 200)
737
737
  });
738
738
  }
739
- async registerDevice() {
740
- return this.call("POST", "/v1/devices");
739
+ async registerDevice(req) {
740
+ return this.call("POST", "/v1/devices", { body: req });
741
741
  }
742
742
  async listDeviceFreeSites(deviceId, credential) {
743
743
  return this.call("GET", "/v1/devices/free-sites", {
@@ -1995,6 +1995,24 @@ function boundDiagnostics(processCwd, selected, snapshot = { supported: false, r
1995
1995
 
1996
1996
  // src/tools/result.ts
1997
1997
  import { z } from "zod";
1998
+ var TARGET_MCP_TOOL_NAMES = [
1999
+ "init",
2000
+ "help",
2001
+ "analyze",
2002
+ "deploy",
2003
+ "refresh",
2004
+ "status",
2005
+ "rotate",
2006
+ "plans",
2007
+ "subscribe",
2008
+ "bind",
2009
+ "billing",
2010
+ "portal",
2011
+ "recover",
2012
+ "change",
2013
+ "support",
2014
+ "report"
2015
+ ];
1998
2016
  var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
1999
2017
  schemaVersion: z.literal(1),
2000
2018
  outcome: z.enum([
@@ -2010,13 +2028,41 @@ var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
2010
2028
  operationId: z.string().optional(),
2011
2029
  summary: z.string(),
2012
2030
  data: z.record(z.string(), z.unknown()),
2031
+ decision: z.object({
2032
+ decisionVersion: z.literal(1),
2033
+ prompt: z.string(),
2034
+ selectionMode: z.literal("single"),
2035
+ selectionRequired: z.literal(true),
2036
+ defaultOptionId: z.null(),
2037
+ options: z.array(
2038
+ z.object({
2039
+ id: z.string(),
2040
+ label: z.string(),
2041
+ description: z.string(),
2042
+ consequences: z.array(z.string()),
2043
+ nextAction: z.discriminatedUnion("type", [
2044
+ z.object({
2045
+ type: z.literal("call_tool"),
2046
+ tool: z.enum(TARGET_MCP_TOOL_NAMES),
2047
+ arguments: z.record(z.string(), z.unknown()),
2048
+ reasonCode: z.string().optional()
2049
+ }),
2050
+ z.object({ type: z.literal("open_url"), url: z.string() }),
2051
+ z.object({ type: z.literal("none") })
2052
+ ])
2053
+ })
2054
+ )
2055
+ }).optional(),
2013
2056
  userAction: z.object({
2014
2057
  type: z.enum(["open_url", "confirm_in_mcp", "configure_dns", "select_site"]),
2015
2058
  provider: z.enum(["stripe", "sakupa"]).optional(),
2016
2059
  url: z.string().optional(),
2017
2060
  expiresAt: z.string().optional(),
2018
2061
  expectedOutcome: z.string(),
2019
- resumeWith: z.object({ tool: z.string(), arguments: z.record(z.string(), z.unknown()) }).optional(),
2062
+ resumeWith: z.object({
2063
+ tool: z.enum(TARGET_MCP_TOOL_NAMES),
2064
+ arguments: z.record(z.string(), z.unknown())
2065
+ }).optional(),
2020
2066
  options: z.array(
2021
2067
  z.object({
2022
2068
  label: z.string(),
@@ -2027,7 +2073,7 @@ var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
2027
2073
  }).optional(),
2028
2074
  nextActions: z.array(
2029
2075
  z.object({
2030
- tool: z.string(),
2076
+ tool: z.enum(TARGET_MCP_TOOL_NAMES),
2031
2077
  arguments: z.record(z.string(), z.unknown()).optional(),
2032
2078
  allowed: z.boolean(),
2033
2079
  reasonCode: z.string().optional()
@@ -2211,7 +2257,7 @@ function toolError(e) {
2211
2257
  }
2212
2258
 
2213
2259
  // src/tools/definitions.ts
2214
- import { randomUUID as randomUUID5 } from "node:crypto";
2260
+ import { randomUUID as randomUUID6 } from "node:crypto";
2215
2261
  import { promises as fs2 } from "node:fs";
2216
2262
  import { join as join9, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
2217
2263
  import { z as z2 } from "zod";
@@ -2939,6 +2985,7 @@ import {
2939
2985
  unlinkSync as unlinkSync2,
2940
2986
  writeFileSync as writeFileSync4
2941
2987
  } from "node:fs";
2988
+ import { randomUUID as randomUUID4 } from "node:crypto";
2942
2989
  import { homedir as homedir3 } from "node:os";
2943
2990
  import { dirname as dirname4, join as join6 } from "node:path";
2944
2991
  var DEVICE_LOCK_STALE_MS = 3e4;
@@ -2948,10 +2995,22 @@ function deviceRegistryPath() {
2948
2995
  return join6(base, ".sakupa", "devices.json");
2949
2996
  }
2950
2997
  var deviceLockPath = () => join6(dirname4(deviceRegistryPath()), "devices.lock");
2951
- function releaseDeviceLock(fd2) {
2998
+ function lockTokenAt(path) {
2952
2999
  try {
2953
- closeSync(fd2);
3000
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
3001
+ return typeof parsed.token === "string" ? parsed.token : null;
3002
+ } catch {
3003
+ return null;
3004
+ }
3005
+ }
3006
+ function ownsDeviceLock(lock) {
3007
+ return lockTokenAt(deviceLockPath()) === lock.token;
3008
+ }
3009
+ function releaseDeviceLock(lock) {
3010
+ try {
3011
+ closeSync(lock.fd);
2954
3012
  } finally {
3013
+ if (!ownsDeviceLock(lock)) return;
2955
3014
  try {
2956
3015
  unlinkSync2(deviceLockPath());
2957
3016
  } catch {
@@ -2966,9 +3025,13 @@ async function acquireDeviceLock(apiBaseUrl) {
2966
3025
  const existing = loadDeviceBinding(apiBaseUrl);
2967
3026
  if (existing) return existing;
2968
3027
  try {
3028
+ const token = randomUUID4();
2969
3029
  const fd2 = openSync(path, "wx", 384);
2970
- writeFileSync4(fd2, JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() }));
2971
- return fd2;
3030
+ writeFileSync4(
3031
+ fd2,
3032
+ JSON.stringify({ token, pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })
3033
+ );
3034
+ return { fd: fd2, token };
2972
3035
  } catch {
2973
3036
  try {
2974
3037
  if (Date.now() - statSync2(path).mtimeMs >= DEVICE_LOCK_STALE_MS) {
@@ -2989,13 +3052,16 @@ async function acquireDeviceLock(apiBaseUrl) {
2989
3052
  }
2990
3053
  function readRegistry() {
2991
3054
  const path = deviceRegistryPath();
2992
- if (!existsSync5(path)) return { schemaVersion: 1, environments: {} };
3055
+ if (!existsSync5(path)) {
3056
+ return { schemaVersion: 1, environments: {}, pendingRegistrations: {} };
3057
+ }
2993
3058
  try {
2994
3059
  const parsed = JSON.parse(readFileSync4(path, "utf8"));
2995
3060
  if (parsed.schemaVersion !== 1 || !parsed.environments || typeof parsed.environments !== "object") {
2996
3061
  throw new Error("unsupported device registry schema");
2997
3062
  }
2998
- return { schemaVersion: 1, environments: parsed.environments };
3063
+ const pendingRegistrations = "pendingRegistrations" in parsed && parsed.pendingRegistrations && typeof parsed.pendingRegistrations === "object" ? parsed.pendingRegistrations : {};
3064
+ return { schemaVersion: 1, environments: parsed.environments, pendingRegistrations };
2999
3065
  } catch (error) {
3000
3066
  throw new Error(
3001
3067
  `Sakupa device registry is unreadable at ${path}: ${error instanceof Error ? error.message : String(error)}. Run help; do not search old project directories.`
@@ -3024,19 +3090,38 @@ async function ensureDeviceBinding(client, apiBaseUrl) {
3024
3090
  const existing = loadDeviceBinding(apiBaseUrl);
3025
3091
  if (existing) return existing;
3026
3092
  const lock = await acquireDeviceLock(apiBaseUrl);
3027
- if (typeof lock !== "number") return lock;
3093
+ if (!("fd" in lock)) return lock;
3028
3094
  try {
3029
3095
  const afterLock = loadDeviceBinding(apiBaseUrl);
3030
3096
  if (afterLock) return afterLock;
3031
- const created = await client.registerDevice();
3032
- const registry = readRegistry();
3097
+ let registry = readRegistry();
3098
+ let pending = registry.pendingRegistrations[apiBaseUrl];
3099
+ if (!pending) {
3100
+ pending = {
3101
+ operationId: randomUUID4(),
3102
+ deviceId: randomUUID4(),
3103
+ credential: generateCredential(),
3104
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3105
+ };
3106
+ registry.pendingRegistrations[apiBaseUrl] = pending;
3107
+ writeRegistry(registry);
3108
+ }
3109
+ const created = await client.registerDevice({
3110
+ operationId: pending.operationId,
3111
+ deviceId: pending.deviceId,
3112
+ credential: pending.credential
3113
+ });
3033
3114
  const binding = {
3034
3115
  deviceId: created.deviceId,
3035
3116
  credential: created.credential,
3036
3117
  createdAt: created.createdAt
3037
3118
  };
3038
- registry.environments[apiBaseUrl] = binding;
3039
- writeRegistry(registry);
3119
+ if (ownsDeviceLock(lock)) {
3120
+ registry = readRegistry();
3121
+ registry.environments[apiBaseUrl] = binding;
3122
+ delete registry.pendingRegistrations[apiBaseUrl];
3123
+ writeRegistry(registry);
3124
+ }
3040
3125
  return binding;
3041
3126
  } finally {
3042
3127
  releaseDeviceLock(lock);
@@ -3321,7 +3406,7 @@ import {
3321
3406
  rmSync as rmSync2,
3322
3407
  writeFileSync as writeFileSync6
3323
3408
  } from "node:fs";
3324
- import { randomUUID as randomUUID4 } from "node:crypto";
3409
+ import { randomUUID as randomUUID5 } from "node:crypto";
3325
3410
  import { join as join8 } from "node:path";
3326
3411
  var ROTATION_FILE = "rotation.json";
3327
3412
  function credentialRotationPath(projectDir) {
@@ -3382,7 +3467,7 @@ function writeCredentialRotation(projectDir, file) {
3382
3467
  const directory = join8(projectDir, ".sakupa");
3383
3468
  mkdirSync6(directory, { recursive: true, mode: 448 });
3384
3469
  const target = credentialRotationPath(projectDir);
3385
- const temporary = join8(directory, `.rotation-${randomUUID4()}.tmp`);
3470
+ const temporary = join8(directory, `.rotation-${randomUUID5()}.tmp`);
3386
3471
  writeFileSync6(temporary, `${JSON.stringify(file, null, 2)}
3387
3472
  `, {
3388
3473
  encoding: "utf8",
@@ -3477,6 +3562,176 @@ async function resumeCredentialRotation(client, projectDir, site, apiBaseUrl) {
3477
3562
  };
3478
3563
  }
3479
3564
 
3565
+ // src/tools/decision.ts
3566
+ var FORBIDDEN_DECISION_ARGUMENT_KEYS = /* @__PURE__ */ new Set([
3567
+ "credential",
3568
+ "candidatecredential",
3569
+ "devicecredential",
3570
+ "password",
3571
+ "secret",
3572
+ "privatekey",
3573
+ "accesstoken",
3574
+ "refreshtoken"
3575
+ ]);
3576
+ function assertDecisionArgumentsSafe(value, path = "arguments") {
3577
+ if (typeof value === "string") {
3578
+ if (/^sk_[A-Za-z0-9_-]{20,}$/.test(value)) {
3579
+ throw new Error(`Decision ${path} contains a credential-like value.`);
3580
+ }
3581
+ return;
3582
+ }
3583
+ if (Array.isArray(value)) {
3584
+ value.forEach((entry, index) => assertDecisionArgumentsSafe(entry, `${path}[${index}]`));
3585
+ return;
3586
+ }
3587
+ if (typeof value !== "object" || value === null) return;
3588
+ for (const [key, entry] of Object.entries(value)) {
3589
+ const normalizedKey = key.replace(/[_-]/g, "").toLowerCase();
3590
+ if (FORBIDDEN_DECISION_ARGUMENT_KEYS.has(normalizedKey)) {
3591
+ throw new Error(`Decision ${path}.${key} contains a forbidden secret field.`);
3592
+ }
3593
+ assertDecisionArgumentsSafe(entry, `${path}.${key}`);
3594
+ }
3595
+ }
3596
+ function buildDecisionContract(prompt, options) {
3597
+ const normalizedPrompt = prompt.trim();
3598
+ if (!normalizedPrompt) throw new Error("Decision prompt must not be empty.");
3599
+ if (options.length < 2) throw new Error("A decision must contain at least two options.");
3600
+ const ids = /* @__PURE__ */ new Set();
3601
+ let actionableOptions = 0;
3602
+ let noActionOptions = 0;
3603
+ for (const option of options) {
3604
+ if (!/^[a-z][a-z0-9_]*$/.test(option.id)) {
3605
+ throw new Error(`Invalid decision option id: ${option.id}`);
3606
+ }
3607
+ if (ids.has(option.id)) throw new Error(`Duplicate decision option id: ${option.id}`);
3608
+ ids.add(option.id);
3609
+ if (!option.label.trim() || !option.description.trim()) {
3610
+ throw new Error(`Decision option ${option.id} requires a label and description.`);
3611
+ }
3612
+ if (option.nextAction.type === "none") {
3613
+ noActionOptions += 1;
3614
+ continue;
3615
+ }
3616
+ actionableOptions += 1;
3617
+ if (option.nextAction.type === "call_tool" && !TARGET_MCP_TOOL_NAMES.includes(option.nextAction.tool)) {
3618
+ throw new Error(`Unknown MCP decision tool: ${String(option.nextAction.tool)}`);
3619
+ }
3620
+ if (option.nextAction.type === "call_tool") {
3621
+ assertDecisionArgumentsSafe(option.nextAction.arguments);
3622
+ }
3623
+ }
3624
+ if (actionableOptions === 0) throw new Error("A decision requires an actionable option.");
3625
+ if (noActionOptions === 0) throw new Error("A decision requires a no-action exit option.");
3626
+ return {
3627
+ decisionVersion: 1,
3628
+ prompt: normalizedPrompt,
3629
+ selectionMode: "single",
3630
+ selectionRequired: true,
3631
+ defaultOptionId: null,
3632
+ options
3633
+ };
3634
+ }
3635
+ function formatDecisionFallback(decision) {
3636
+ const options = decision.options.map((option, index) => {
3637
+ const consequences = option.consequences.length === 0 ? "" : `
3638
+ Consequences: ${option.consequences.join(" ")}`;
3639
+ let exactAction;
3640
+ switch (option.nextAction.type) {
3641
+ case "call_tool":
3642
+ exactAction = `If the user selects this option, call ${option.nextAction.tool} with these exact arguments: ${JSON.stringify(option.nextAction.arguments)}.`;
3643
+ break;
3644
+ case "open_url":
3645
+ exactAction = `If the user selects this option, present this exact URL: ${option.nextAction.url}.`;
3646
+ break;
3647
+ case "none":
3648
+ exactAction = "If the user selects this option, call no tool and make no change.";
3649
+ break;
3650
+ }
3651
+ return `${index + 1}. [${option.id}] ${option.label}
3652
+ ${option.description}${consequences}
3653
+ ${exactAction}`;
3654
+ });
3655
+ return `USER DECISION REQUIRED: ${decision.prompt}
3656
+ No option is selected by default. Present every option to the user, do not choose on their behalf, and never reconstruct or guess tool arguments.
3657
+
3658
+ ` + options.join("\n\n");
3659
+ }
3660
+ function decisionToolResult(input) {
3661
+ const decision = buildDecisionContract(input.prompt, input.options);
3662
+ const actionable = decision.options.filter(
3663
+ (option) => option.nextAction.type === "call_tool"
3664
+ );
3665
+ const nextActions = actionable.map((option) => ({
3666
+ tool: option.nextAction.tool,
3667
+ arguments: option.nextAction.arguments,
3668
+ allowed: true,
3669
+ ...option.nextAction.reasonCode === void 0 ? {} : { reasonCode: option.nextAction.reasonCode }
3670
+ }));
3671
+ let userAction;
3672
+ if (input.legacyUserAction?.type === "confirm_in_mcp" && actionable.length === 1) {
3673
+ const option = actionable[0];
3674
+ if (option !== void 0) {
3675
+ userAction = {
3676
+ type: "confirm_in_mcp",
3677
+ provider: input.legacyUserAction.provider,
3678
+ expectedOutcome: option.description,
3679
+ resumeWith: {
3680
+ tool: option.nextAction.tool,
3681
+ arguments: option.nextAction.arguments
3682
+ }
3683
+ };
3684
+ }
3685
+ } else if (input.legacyUserAction?.type === "select_site") {
3686
+ userAction = {
3687
+ type: "select_site",
3688
+ provider: input.legacyUserAction.provider,
3689
+ expectedOutcome: "Apply only the option explicitly selected by the user.",
3690
+ options: actionable.map((option) => ({
3691
+ label: option.label,
3692
+ value: typeof option.nextAction.arguments["reuseSiteUrl"] === "string" ? option.nextAction.arguments["reuseSiteUrl"] : option.id,
3693
+ expectedOutcome: option.description
3694
+ }))
3695
+ };
3696
+ }
3697
+ return structuredToolResult({
3698
+ schemaVersion: 1,
3699
+ outcome: input.outcome ?? "waiting_user",
3700
+ resultCode: input.resultCode,
3701
+ ...input.operationId === void 0 ? {} : { operationId: input.operationId },
3702
+ summary: `${input.summary}
3703
+
3704
+ ${formatDecisionFallback(decision)}`,
3705
+ data: input.data,
3706
+ decision,
3707
+ ...userAction === void 0 ? {} : { userAction },
3708
+ nextActions
3709
+ });
3710
+ }
3711
+ function callToolDecisionOption(input) {
3712
+ return {
3713
+ id: input.id,
3714
+ label: input.label,
3715
+ description: input.description,
3716
+ consequences: input.consequences ?? [],
3717
+ nextAction: {
3718
+ type: "call_tool",
3719
+ tool: input.tool,
3720
+ arguments: input.arguments,
3721
+ ...input.reasonCode === void 0 ? {} : { reasonCode: input.reasonCode }
3722
+ }
3723
+ };
3724
+ }
3725
+ function noActionDecisionOption(input) {
3726
+ return {
3727
+ id: input?.id ?? "cancel",
3728
+ label: input?.label ?? "Do not continue",
3729
+ description: input?.description ?? "Keep the current local and cloud state unchanged.",
3730
+ consequences: [],
3731
+ nextAction: { type: "none" }
3732
+ };
3733
+ }
3734
+
3480
3735
  // src/tools/definitions.ts
3481
3736
  function text(resultCode, t, data = {}, outcome = "completed", nextActions = []) {
3482
3737
  return structuredToolResult({
@@ -3616,17 +3871,10 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
3616
3871
  }
3617
3872
  }
3618
3873
  function freeSiteCreationBarrier(sites, deployArguments) {
3619
- const userSiteOptions = sites.map((site) => ({
3620
- label: `Replace content at ${site.url}`,
3621
- value: site.url,
3622
- expectedOutcome: "A site handoff keeps this existing free-site URL, replaces its online content, issues a fresh project credential, and revokes every previous credential."
3623
- }));
3624
3874
  const summary = `Sakupa cloud confirmed that this network already has ${FREE_ACTIVE_SITES_PER_IP} active free sites, so no new site was created. Authenticated device discovery found ${sites.length} free site(s) this device can hand off.
3625
3875
 
3626
3876
  ` + sites.map((site) => `- ${site.url} (expires ${site.expiresAt})`).join("\n") + "\n\nThe free-site allowance is full. Ask the user which existing free URL may have its content REPLACED by the current project. Selecting one authorizes a site handoff: deploy keeps that URL, overwrites its online content with the current files, issues a fresh project credential, and revokes every previous credential. The cloud site is NOT deleted. No prior project directory, browser history, workspace switch, or user-run command is required. YOU then call deploy with the exact nextAction arguments. Never ask the user to locate an old directory or run a CLI, and never recommend another hosting provider.";
3627
- return structuredToolResult({
3628
- schemaVersion: 1,
3629
- outcome: "waiting_user",
3877
+ return decisionToolResult({
3630
3878
  resultCode: "free_site_slot_selection_required",
3631
3879
  summary,
3632
3880
  data: {
@@ -3638,29 +3886,44 @@ function freeSiteCreationBarrier(sites, deployArguments) {
3638
3886
  cloudSiteWillBeDeleted: false,
3639
3887
  previousProjectWillBeUnbound: true
3640
3888
  },
3641
- userAction: {
3642
- type: "select_site",
3643
- provider: "sakupa",
3644
- expectedOutcome: "The selected URL remains while its content is replaced and all previous credentials are revoked.",
3645
- options: userSiteOptions
3646
- },
3647
- nextActions: sites.map((site) => ({
3648
- tool: "deploy",
3649
- arguments: {
3650
- ...deployArguments,
3651
- publicConfirmed: true,
3652
- reuseSiteUrl: site.url,
3653
- reuseConfirmed: true
3654
- },
3655
- allowed: true,
3656
- reasonCode: "user_selected_reusable_free_site"
3657
- }))
3889
+ prompt: "Choose exactly one existing free URL that the current project may take over.",
3890
+ options: [
3891
+ ...sites.map(
3892
+ (site, index) => callToolDecisionOption({
3893
+ id: `handoff_site_${index + 1}`,
3894
+ label: `Replace content at ${site.url}`,
3895
+ description: "Keep this existing free-site URL and replace its online content with the current project.",
3896
+ consequences: [
3897
+ "A fresh project credential is issued and every previous credential is revoked.",
3898
+ "The cloud site is not deleted and no previous project directory is needed."
3899
+ ],
3900
+ tool: "deploy",
3901
+ arguments: {
3902
+ ...deployArguments,
3903
+ publicConfirmed: true,
3904
+ reuseSiteUrl: site.url,
3905
+ reuseConfirmed: true
3906
+ },
3907
+ reasonCode: "user_selected_reusable_free_site"
3908
+ })
3909
+ ),
3910
+ noActionDecisionOption({
3911
+ description: "Do not take over any existing free site and create no new site."
3912
+ })
3913
+ ],
3914
+ legacyUserAction: { type: "select_site", provider: "sakupa" }
3658
3915
  });
3659
3916
  }
3660
3917
  async function discoverDeviceFreeSites(client, apiBaseUrl, device) {
3918
+ let cloudSites = (await client.listDeviceFreeSites(device.deviceId, device.credential)).sites;
3919
+ const alreadyOwned = new Set(cloudSites.map((site) => site.siteId));
3920
+ let claimedAny = false;
3661
3921
  for (const record of listRecentCreations(Date.now(), apiBaseUrl)) {
3662
3922
  const state = loadSiteFile(record.projectDir);
3663
3923
  if (state.kind !== "ok" || state.file.siteId !== record.siteId) continue;
3924
+ if (state.file.apiBaseUrl !== "" && state.file.apiBaseUrl !== apiBaseUrl) continue;
3925
+ if (alreadyOwned.has(record.siteId)) continue;
3926
+ const environmentIsKnown = record.apiBaseUrl === apiBaseUrl || state.file.apiBaseUrl === apiBaseUrl;
3664
3927
  try {
3665
3928
  await client.claimDeviceFreeSite(
3666
3929
  record.siteId,
@@ -3668,9 +3931,11 @@ async function discoverDeviceFreeSites(client, apiBaseUrl, device) {
3668
3931
  device.deviceId,
3669
3932
  device.credential
3670
3933
  );
3934
+ claimedAny = true;
3935
+ alreadyOwned.add(record.siteId);
3671
3936
  } catch (error) {
3672
3937
  if (isSakupaError(error) && ["not_found", "state_conflict"].includes(error.code)) {
3673
- removeCreation(record.siteId);
3938
+ if (environmentIsKnown) removeCreation(record.siteId);
3674
3939
  continue;
3675
3940
  }
3676
3941
  if (!isSakupaError(error) || error.code !== "unauthorized") {
@@ -3678,7 +3943,10 @@ async function discoverDeviceFreeSites(client, apiBaseUrl, device) {
3678
3943
  }
3679
3944
  }
3680
3945
  }
3681
- return (await client.listDeviceFreeSites(device.deviceId, device.credential)).sites;
3946
+ if (claimedAny) {
3947
+ cloudSites = (await client.listDeviceFreeSites(device.deviceId, device.credential)).sites;
3948
+ }
3949
+ return cloudSites;
3682
3950
  }
3683
3951
  function outputDirectoryChain(projectRoot, outputAbs) {
3684
3952
  const rel = relative3(projectRoot, outputAbs);
@@ -3775,18 +4043,39 @@ Next action: ${analysis.suggestedNextAction}`,
3775
4043
  const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
3776
4044
  const recordedOutputDir = ctx.projectMarker?.outputDir;
3777
4045
  if (recordedOutputDir !== void 0 && resolve5(ctx.projectDir, recordedOutputDir) !== resolve5(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
3778
- return structuredToolResult({
3779
- schemaVersion: 1,
3780
- outcome: "waiting_user",
4046
+ const confirmation = { outputDirChangeConfirmed: true };
4047
+ const confirmArguments = {
4048
+ ...args,
4049
+ outputDir: effectiveOutputDir,
4050
+ ...confirmation
4051
+ };
4052
+ return decisionToolResult({
3781
4053
  resultCode: "publish_directory_change_confirmation_required",
3782
4054
  summary: `This initialized project last published from "${recordedOutputDir}", but this request selected "${effectiveOutputDir}". Nothing was uploaded and the site was not changed. Show both paths to the user; only after explicit confirmation call deploy again with outputDirChangeConfirmed: true.`,
3783
4055
  data: {
3784
4056
  projectDir: ctx.projectDir,
3785
4057
  previousOutputDir: recordedOutputDir,
3786
4058
  requestedOutputDir: effectiveOutputDir,
3787
- confirmationField: "outputDirChangeConfirmed"
4059
+ confirmationField: "outputDirChangeConfirmed",
4060
+ confirmation,
4061
+ confirmArguments
3788
4062
  },
3789
- nextActions: [{ tool: "deploy", allowed: true, reasonCode: "explicit_confirmation" }]
4063
+ prompt: `Use the newly selected publish directory "${effectiveOutputDir}"?`,
4064
+ options: [
4065
+ callToolDecisionOption({
4066
+ id: "use_new_publish_directory",
4067
+ label: `Use ${effectiveOutputDir}`,
4068
+ description: "Publish this project from the newly selected directory.",
4069
+ consequences: [`The recorded publish directory changes from ${recordedOutputDir}.`],
4070
+ tool: "deploy",
4071
+ arguments: confirmArguments,
4072
+ reasonCode: "explicit_confirmation"
4073
+ }),
4074
+ noActionDecisionOption({
4075
+ description: "Keep the recorded publish directory and upload nothing."
4076
+ })
4077
+ ],
4078
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
3790
4079
  });
3791
4080
  }
3792
4081
  const files = analysis.files;
@@ -3841,24 +4130,37 @@ Next action: ${analysis.suggestedNextAction}`,
3841
4130
  }
3842
4131
  if (nestedMarker.kind === "ok") {
3843
4132
  if (args.sakupaRelocationConfirmed !== true) {
3844
- return structuredToolResult({
3845
- schemaVersion: 1,
3846
- outcome: "waiting_user",
4133
+ const confirmation = { sakupaRelocationConfirmed: true };
4134
+ const confirmArguments = { ...args, ...confirmation };
4135
+ return decisionToolResult({
3847
4136
  resultCode: "sakupa_relocation_confirmation_required",
3848
4137
  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.`,
3849
4138
  data: {
3850
4139
  projectRoot: ctx.projectDir,
3851
4140
  misplacedSakupaDirectory: join9(candidateDir, ".sakupa"),
3852
4141
  targetSakupaDirectory: join9(ctx.projectDir, ".sakupa"),
3853
- confirmationField: "sakupaRelocationConfirmed"
4142
+ confirmationField: "sakupaRelocationConfirmed",
4143
+ confirmation,
4144
+ confirmArguments
3854
4145
  },
3855
- nextActions: [
3856
- {
4146
+ prompt: "Move the nested Sakupa project binding to the active workspace Root?",
4147
+ options: [
4148
+ callToolDecisionOption({
4149
+ id: "relocate_sakupa_binding",
4150
+ label: "Move the Sakupa binding to the workspace Root",
4151
+ description: "Validate and relocate the nested Sakupa binding, then continue this deployment.",
4152
+ consequences: [
4153
+ "Sakupa preserves valid credentials and refuses conflicting bindings."
4154
+ ],
3857
4155
  tool: "deploy",
3858
- allowed: true,
4156
+ arguments: confirmArguments,
3859
4157
  reasonCode: "explicit_sakupa_relocation_confirmation"
3860
- }
3861
- ]
4158
+ }),
4159
+ noActionDecisionOption({
4160
+ description: "Leave both directories unchanged and upload nothing."
4161
+ })
4162
+ ],
4163
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
3862
4164
  });
3863
4165
  }
3864
4166
  markerRelocatedFrom.push(candidateDir);
@@ -3981,19 +4283,41 @@ Next action: ${analysis.suggestedNextAction}`,
3981
4283
  deleteProjectMarker(dir);
3982
4284
  }
3983
4285
  if (!existing && args.reuseSiteUrl === void 0 && args.publicConfirmed !== true) {
3984
- return text(
3985
- "public_deployment_confirmation_required",
3986
- `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy with publicConfirmed: true.`,
3987
- { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
3988
- "waiting_user"
3989
- );
4286
+ const confirmation = { publicConfirmed: true };
4287
+ const confirmArguments = { ...args, ...confirmation };
4288
+ return decisionToolResult({
4289
+ resultCode: "public_deployment_confirmation_required",
4290
+ summary: `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Nothing has been uploaded or made public yet. The exact confirmation field is publicConfirmed: true.`,
4291
+ data: {
4292
+ publicUrlLifetimeHours: FREE_SITE_TTL_HOURS,
4293
+ confirmationField: "publicConfirmed",
4294
+ confirmation,
4295
+ confirmArguments
4296
+ },
4297
+ prompt: "Create the first public free-site preview for this project?",
4298
+ options: [
4299
+ callToolDecisionOption({
4300
+ id: "create_public_preview",
4301
+ label: "Create the public preview",
4302
+ description: `Publish the selected files at a public URL for ${FREE_SITE_TTL_HOURS} hours.`,
4303
+ consequences: ["Anyone with the generated URL can open the site."],
4304
+ tool: "deploy",
4305
+ arguments: confirmArguments,
4306
+ reasonCode: "explicit_public_deployment_confirmation"
4307
+ }),
4308
+ noActionDecisionOption({
4309
+ description: "Keep the project private and upload nothing."
4310
+ })
4311
+ ],
4312
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
4313
+ });
3990
4314
  }
3991
4315
  if (!existing) {
3992
4316
  if (args.reuseSiteUrl !== void 0) {
3993
4317
  if (args.reuseConfirmed !== true) {
3994
- return structuredToolResult({
3995
- schemaVersion: 1,
3996
- outcome: "waiting_user",
4318
+ const confirmation = { reuseConfirmed: true };
4319
+ const confirmArguments = { ...args, publicConfirmed: true, ...confirmation };
4320
+ return decisionToolResult({
3997
4321
  resultCode: "free_site_reuse_confirmation_required",
3998
4322
  summary: `Nothing was changed. Reusing ${args.reuseSiteUrl} will replace all online content at that URL with the current project, issue a fresh credential here, and revoke every previous credential automatically. No old directory is needed. Show these consequences and call deploy with reuseConfirmed:true only after the user explicitly selects this URL.`,
3999
4323
  data: {
@@ -4001,25 +4325,29 @@ Next action: ${analysis.suggestedNextAction}`,
4001
4325
  cloudSiteWillBeDeleted: false,
4002
4326
  onlineContentWillBeReplaced: true,
4003
4327
  previousProjectWillBeUnbound: true,
4004
- confirmationField: "reuseConfirmed"
4328
+ confirmationField: "reuseConfirmed",
4329
+ confirmation,
4330
+ confirmArguments
4005
4331
  },
4006
- userAction: {
4007
- type: "confirm_in_mcp",
4008
- provider: "sakupa",
4009
- expectedOutcome: "Replace the selected free URL content and move its local project binding.",
4010
- resumeWith: {
4332
+ prompt: `Take over ${args.reuseSiteUrl} with the current project?`,
4333
+ options: [
4334
+ callToolDecisionOption({
4335
+ id: "confirm_site_handoff",
4336
+ label: `Take over ${args.reuseSiteUrl}`,
4337
+ description: "Keep the selected URL and replace all online content with the current project.",
4338
+ consequences: [
4339
+ "A fresh credential is issued here and every previous credential is revoked.",
4340
+ "The previous project becomes unbound; the cloud site is not deleted."
4341
+ ],
4011
4342
  tool: "deploy",
4012
- arguments: { ...args, publicConfirmed: true, reuseConfirmed: true }
4013
- }
4014
- },
4015
- nextActions: [
4016
- {
4017
- tool: "deploy",
4018
- arguments: { ...args, publicConfirmed: true, reuseConfirmed: true },
4019
- allowed: true,
4343
+ arguments: confirmArguments,
4020
4344
  reasonCode: "explicit_free_site_reuse_confirmation"
4021
- }
4022
- ]
4345
+ }),
4346
+ noActionDecisionOption({
4347
+ description: "Keep the selected site and current project unchanged."
4348
+ })
4349
+ ],
4350
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
4023
4351
  });
4024
4352
  }
4025
4353
  }
@@ -4395,7 +4723,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4395
4723
  {
4396
4724
  siteId: site.siteId,
4397
4725
  plan: args.plan,
4398
- idempotencyKey: randomUUID5()
4726
+ idempotencyKey: randomUUID6()
4399
4727
  },
4400
4728
  site.credential
4401
4729
  );
@@ -4846,10 +5174,59 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
4846
5174
  }
4847
5175
  if (args.action === "status") {
4848
5176
  const res2 = await ctx.client.getRecoveryStatus(verificationId);
5177
+ if (res2.readyToComplete) {
5178
+ const revokeArguments = {
5179
+ action: "complete",
5180
+ verificationId,
5181
+ preserveExistingCredentials: false,
5182
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
5183
+ };
5184
+ const preserveArguments = {
5185
+ ...revokeArguments,
5186
+ preserveExistingCredentials: true
5187
+ };
5188
+ return decisionToolResult({
5189
+ resultCode: "domain_recovery_ready",
5190
+ summary: "DNS control is verified and recovery is ready to complete. Nothing was completed yet. The user must choose whether previous site credentials remain valid.",
5191
+ data: {
5192
+ recovery: res2,
5193
+ credentialPolicyChoices: ["revoke_previous", "preserve_previous"]
5194
+ },
5195
+ prompt: "How should Sakupa handle the site credentials that existed before recovery?",
5196
+ options: [
5197
+ callToolDecisionOption({
5198
+ id: "revoke_previous_credentials",
5199
+ label: "Revoke all previous credentials",
5200
+ description: "Complete recovery with the new local credential and revoke every previous credential.",
5201
+ consequences: [
5202
+ "Old project folders and credential backups can no longer manage the site."
5203
+ ],
5204
+ tool: "recover",
5205
+ arguments: revokeArguments,
5206
+ reasonCode: "user_selected_secure_recovery"
5207
+ }),
5208
+ callToolDecisionOption({
5209
+ id: "preserve_previous_credentials",
5210
+ label: "Keep previous credentials valid",
5211
+ description: "Complete recovery with the new local credential without revoking existing credentials.",
5212
+ consequences: [
5213
+ "Any old project folder or leaked credential that still works retains site authority."
5214
+ ],
5215
+ tool: "recover",
5216
+ arguments: preserveArguments,
5217
+ reasonCode: "user_selected_credential_preservation"
5218
+ }),
5219
+ noActionDecisionOption({
5220
+ label: "Do not complete recovery yet",
5221
+ description: "Keep the verified recovery pending and change no credential."
5222
+ })
5223
+ ]
5224
+ });
5225
+ }
4849
5226
  return structuredToolResult({
4850
5227
  schemaVersion: 1,
4851
- outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
4852
- resultCode: res2.status === "expired" ? "domain_recovery_expired" : res2.readyToComplete ? "domain_recovery_ready" : "domain_recovery_pending_dns",
5228
+ outcome: res2.status === "expired" ? "expired" : "pending_provider",
5229
+ resultCode: res2.status === "expired" ? "domain_recovery_expired" : "domain_recovery_pending_dns",
4853
5230
  summary: `DNS recovery verification status: ${res2.status}`,
4854
5231
  data: { recovery: res2 },
4855
5232
  nextActions: [
@@ -4860,8 +5237,8 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
4860
5237
  verificationId,
4861
5238
  ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
4862
5239
  },
4863
- allowed: res2.readyToComplete,
4864
- ...res2.readyToComplete ? {} : { reasonCode: res2.status }
5240
+ allowed: false,
5241
+ reasonCode: res2.status
4865
5242
  }
4866
5243
  ]
4867
5244
  });
@@ -5031,12 +5408,39 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
5031
5408
  };
5032
5409
  if (args.confirmSubmit !== true) {
5033
5410
  const contactNote = args.contactEmail !== void 0 ? "note that their contact email is attached for follow-up. " : "ASK THEM ONCE whether they want to attach a contact email for follow-up (optional \u2014 omit if declined; include it as contactEmail when they do). ";
5034
- return textJson(
5035
- "bug_report_preview_ready",
5036
- `Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Show it to the user, and ${contactNote}Then re-run report with confirmSubmit: true to submit.`,
5037
- payload,
5038
- "preview"
5039
- );
5411
+ const confirmation = { confirmSubmit: true };
5412
+ const confirmArguments = { ...args, ...confirmation };
5413
+ return decisionToolResult({
5414
+ resultCode: "bug_report_preview_ready",
5415
+ outcome: "preview",
5416
+ summary: `Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Show it to the user, and ${contactNote}Exact payload:
5417
+ ${JSON.stringify(payload, null, 2)}`,
5418
+ data: {
5419
+ result: payload,
5420
+ confirmation,
5421
+ confirmArguments,
5422
+ submitted: false
5423
+ },
5424
+ prompt: "Submit this exact sanitized bug report?",
5425
+ options: [
5426
+ callToolDecisionOption({
5427
+ id: "submit_bug_report",
5428
+ label: "Submit the reviewed report",
5429
+ description: "Submit exactly the sanitized payload shown above.",
5430
+ consequences: [
5431
+ args.contactEmail === void 0 ? "No contact email is attached." : "The provided contact email is attached for follow-up."
5432
+ ],
5433
+ tool: "report",
5434
+ arguments: confirmArguments,
5435
+ reasonCode: "explicit_bug_report_submission_confirmation"
5436
+ }),
5437
+ noActionDecisionOption({
5438
+ label: "Do not submit the report",
5439
+ description: "Keep the report local and send nothing to Sakupa."
5440
+ })
5441
+ ],
5442
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
5443
+ });
5040
5444
  }
5041
5445
  const res = await baseCtx.client.reportBug(payload, site?.credential);
5042
5446
  return text(
@@ -5436,8 +5840,18 @@ function registerHelpTools(server, baseCtx) {
5436
5840
  schemaVersion: 1,
5437
5841
  outcome: "completed",
5438
5842
  resultCode: "help_overview",
5439
- summary: 'Sakupa tool overview and parameter names returned. Site handoff moves an existing free URL to the current project and replaces its credential as a safety consequence; credential rotation changes the credential in place solely for security. Both revoke prior values. Use help topic:"terminology" for every site/credential distinction. On any failure call help with topic:"diagnose" before retrying, support or report.',
5440
- data: { tools: catalog, toolOrder: TOOL_TOPICS, terminology: HELP_TERMINOLOGY },
5843
+ summary: `Sakupa tool overview and parameter names returned. Site handoff moves an existing free URL to the current project and replaces its credential as a safety consequence; credential rotation changes the credential in place solely for security. Both revoke prior values. When a result contains decision, present every option, select none by default, and copy only the user's selected option nextAction exactly. Use help topic:"terminology" for every site/credential distinction. On any failure call help with topic:"diagnose" before retrying, support or report.`,
5844
+ data: {
5845
+ tools: catalog,
5846
+ toolOrder: TOOL_TOPICS,
5847
+ terminology: HELP_TERMINOLOGY,
5848
+ decisionOptions: {
5849
+ selectionMode: "single",
5850
+ defaultOptionId: null,
5851
+ presentEveryOption: true,
5852
+ exactNextActionRequired: true
5853
+ }
5854
+ },
5441
5855
  nextActions: []
5442
5856
  });
5443
5857
  }
@@ -5596,9 +6010,7 @@ function registerCredentialTools(server, baseCtx) {
5596
6010
  const status = await ctx.client.getCredentialStatus(site.siteId, site.credential);
5597
6011
  const confirmation = { confirmed: true };
5598
6012
  if (args.confirmed !== true) {
5599
- return structuredToolResult({
5600
- schemaVersion: 1,
5601
- outcome: "waiting_user",
6013
+ return decisionToolResult({
5602
6014
  resultCode: "credential_rotation_confirmation_required",
5603
6015
  summary: `Nothing was changed. Rotating the management credential for ${site.url ?? site.siteId} will generate a new credential locally, save it as the current credential in this project .sakupa/site.json, and revoke EVERY previous credential for this site\u2014including copies in old folders and backups. Rotation is optional and deploy remains available. Current credential created at: ${status.credentialCreatedAt}. Exact confirm arguments: ${JSON.stringify(confirmation)}. Ask the user for explicit approval; never expose credential values.`,
5604
6016
  data: {
@@ -5611,20 +6023,26 @@ function registerCredentialTools(server, baseCtx) {
5611
6023
  previousCredentialsWillBeRevoked: true,
5612
6024
  optional: true
5613
6025
  },
5614
- userAction: {
5615
- type: "confirm_in_mcp",
5616
- provider: "sakupa",
5617
- expectedOutcome: "Generate one new local credential and revoke every previous credential for this site.",
5618
- resumeWith: { tool: "rotate", arguments: confirmation }
5619
- },
5620
- nextActions: [
5621
- {
6026
+ prompt: `Rotate the management credential for ${site.url ?? site.siteId}?`,
6027
+ options: [
6028
+ callToolDecisionOption({
6029
+ id: "rotate_credential",
6030
+ label: "Rotate the management credential",
6031
+ description: "Generate one new local credential and make it the only valid credential for this site.",
6032
+ consequences: [
6033
+ "Every previous credential is revoked, including copies in old folders and backups.",
6034
+ "The site URL and online content do not change."
6035
+ ],
5622
6036
  tool: "rotate",
5623
6037
  arguments: confirmation,
5624
- allowed: true,
5625
6038
  reasonCode: "explicit_credential_rotation_confirmation"
5626
- }
5627
- ]
6039
+ }),
6040
+ noActionDecisionOption({
6041
+ label: "Keep the current credential",
6042
+ description: "Do not rotate; deployment remains available with the current credential."
6043
+ })
6044
+ ],
6045
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
5628
6046
  });
5629
6047
  }
5630
6048
  writeCredentialRotation(ctx.projectDir, {
@@ -5702,6 +6120,14 @@ Workflow:
5702
6120
  payment, refund and other customer-service requests. report is the LAST resort only when
5703
6121
  help explicitly recommends a product bug report, and submission still requires user review.
5704
6122
 
6123
+ Decision-options contract: when a result contains decision, present EVERY numbered option from the
6124
+ tool to the user and wait for their selection. No option is selected by default. Never choose from
6125
+ context, paraphrase a selection into different arguments, or invent another option. After the user
6126
+ selects, copy that option's exact nextAction. Legacy userAction and nextActions mirror the same
6127
+ choice for older clients; decision is the authoritative choice set. A type "none" option means call
6128
+ no tool and change nothing. Stripe-hosted links remain direct links because Stripe itself owns plan
6129
+ selection and confirmation.
6130
+
5705
6131
  Project directory contract: before the first deploy or a new recovery, initialize the intended
5706
6132
  project by calling init with NO path argument. init uses the IDE's exact MCP Root and creates the
5707
6133
  non-secret .sakupa/project.json directly there. The CLI command