@switchbot/openapi-cli 3.5.0 → 3.6.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/README.md +2 -2
  2. package/dist/index.js +2185 -1560
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7622,6 +7622,15 @@ function deriveStatusQueries(entry) {
7622
7622
  safetyTier: "read"
7623
7623
  }));
7624
7624
  }
7625
+ function canonicalizeDeviceType(deviceType) {
7626
+ const catalog = getEffectiveCatalog();
7627
+ const lower = deviceType.toLowerCase();
7628
+ for (const entry of catalog) {
7629
+ if (entry.type.toLowerCase() === lower) return entry.type;
7630
+ if (entry.aliases?.some((a) => a.toLowerCase() === lower)) return entry.type;
7631
+ }
7632
+ return deviceType;
7633
+ }
7625
7634
  function findCatalogEntry(query) {
7626
7635
  const q = query.trim().toLowerCase();
7627
7636
  if (!q) return null;
@@ -7735,7 +7744,7 @@ function getEffectiveCatalog() {
7735
7744
  }
7736
7745
  return Array.from(byType.values());
7737
7746
  }
7738
- var CATALOG_SCHEMA_VERSION, STATUS_FIELD_DESCRIPTIONS, onOff, onOffToggle, lightControls, DEVICE_CATALOG, overlayCache;
7747
+ var CATALOG_SCHEMA_VERSION, STATUS_FIELD_DESCRIPTIONS, onOff, onOffToggle, lightControls, rgbLightControls0To100, rgbOnlyLightControls0To100, DEVICE_CATALOG, overlayCache;
7739
7748
  var init_catalog = __esm({
7740
7749
  "src/devices/catalog.ts"() {
7741
7750
  "use strict";
@@ -7803,6 +7812,15 @@ var init_catalog = __esm({
7803
7812
  { command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] },
7804
7813
  { command: "setColorTemperature", parameter: "2700-6500", description: "Set color temperature (Kelvin)", idempotent: true, exampleParams: ["2700", "4000", "6500"] }
7805
7814
  ];
7815
+ rgbLightControls0To100 = [
7816
+ { command: "setBrightness", parameter: "0-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["0", "50", "100"] },
7817
+ { command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] },
7818
+ { command: "setColorTemperature", parameter: "2700-6500", description: "Set color temperature (Kelvin)", idempotent: true, exampleParams: ["2700", "4000", "6500"] }
7819
+ ];
7820
+ rgbOnlyLightControls0To100 = [
7821
+ { command: "setBrightness", parameter: "0-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["0", "50", "100"] },
7822
+ { command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] }
7823
+ ];
7806
7824
  DEVICE_CATALOG = [
7807
7825
  // ---------- Physical devices ----------
7808
7826
  {
@@ -7835,7 +7853,7 @@ var init_catalog = __esm({
7835
7853
  category: "physical",
7836
7854
  description: "Bluetooth/Wi-Fi electronic deadbolt that locks and unlocks a door via cloud API.",
7837
7855
  role: "security",
7838
- aliases: ["Smart Lock Pro"],
7856
+ aliases: ["Lock", "Smart Lock Pro", "Lock Pro"],
7839
7857
  commands: [
7840
7858
  { command: "lock", parameter: "\u2014", description: "Lock the door", idempotent: true },
7841
7859
  { command: "unlock", parameter: "\u2014", description: "Unlock the door", idempotent: true, safetyTier: "destructive", safetyReason: "Physically unlocks the door \u2014 anyone nearby can open it." },
@@ -7848,6 +7866,7 @@ var init_catalog = __esm({
7848
7866
  category: "physical",
7849
7867
  description: "Compact electronic deadbolt with lock and unlock control; no deadbolt mode.",
7850
7868
  role: "security",
7869
+ aliases: ["Lock Lite"],
7851
7870
  commands: [
7852
7871
  { command: "lock", parameter: "\u2014", description: "Lock the door", idempotent: true },
7853
7872
  { command: "unlock", parameter: "\u2014", description: "Unlock the door", idempotent: true, safetyTier: "destructive", safetyReason: "Physically unlocks the door \u2014 anyone nearby can open it." }
@@ -7859,6 +7878,7 @@ var init_catalog = __esm({
7859
7878
  category: "physical",
7860
7879
  description: "Premium electronic deadbolt with full lock, unlock, and deadbolt control.",
7861
7880
  role: "security",
7881
+ aliases: ["Lock Ultra"],
7862
7882
  commands: [
7863
7883
  { command: "lock", parameter: "\u2014", description: "Lock the door", idempotent: true },
7864
7884
  { command: "unlock", parameter: "\u2014", description: "Unlock the door", idempotent: true, safetyTier: "destructive", safetyReason: "Physically unlocks the door \u2014 anyone nearby can open it." },
@@ -7869,9 +7889,9 @@ var init_catalog = __esm({
7869
7889
  {
7870
7890
  type: "Plug",
7871
7891
  category: "physical",
7872
- description: "Smart wall outlet plug with on/off/toggle control and basic power status.",
7892
+ description: "Smart wall outlet plug with on/off control and basic power status.",
7873
7893
  role: "power",
7874
- commands: onOffToggle,
7894
+ commands: onOff,
7875
7895
  statusFields: ["power", "version"]
7876
7896
  },
7877
7897
  {
@@ -7879,7 +7899,7 @@ var init_catalog = __esm({
7879
7899
  category: "physical",
7880
7900
  description: "Compact smart plug with voltage, current, and daily energy consumption reporting.",
7881
7901
  role: "power",
7882
- aliases: ["Plug Mini (JP)"],
7902
+ aliases: ["Plug Mini (JP)", "Plug Mini (EU)"],
7883
7903
  commands: onOffToggle,
7884
7904
  statusFields: ["voltage", "weight", "electricityOfDay", "electricCurrent", "power", "version"]
7885
7905
  },
@@ -7957,12 +7977,63 @@ var init_catalog = __esm({
7957
7977
  {
7958
7978
  type: "Strip Light",
7959
7979
  category: "physical",
7960
- description: "Addressable LED strip with on/off, brightness, RGB color, and color temperature control.",
7980
+ description: "Addressable LED strip with on/off, brightness, and RGB color control.",
7961
7981
  role: "lighting",
7962
- aliases: ["Strip Light 3"],
7963
- commands: [...onOffToggle, ...lightControls],
7982
+ commands: [
7983
+ ...onOffToggle,
7984
+ { command: "setBrightness", parameter: "1-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["50", "80"] },
7985
+ { command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] }
7986
+ ],
7964
7987
  statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
7965
7988
  },
7989
+ {
7990
+ type: "Floor Lamp",
7991
+ category: "physical",
7992
+ description: "Smart floor lamp with 0-100 brightness, RGB color, and color temperature control.",
7993
+ role: "lighting",
7994
+ commands: [...onOffToggle, ...rgbLightControls0To100],
7995
+ statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
7996
+ },
7997
+ {
7998
+ type: "Strip Light 3",
7999
+ category: "physical",
8000
+ description: "Third-generation strip light with 0-100 brightness, RGB color, and color temperature control.",
8001
+ role: "lighting",
8002
+ commands: [...onOffToggle, ...rgbLightControls0To100],
8003
+ statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
8004
+ },
8005
+ {
8006
+ type: "RGBICWW Strip Light",
8007
+ category: "physical",
8008
+ description: "RGBICWW strip light with 0-100 brightness, RGB color, and color temperature control.",
8009
+ role: "lighting",
8010
+ commands: [...onOffToggle, ...rgbLightControls0To100],
8011
+ statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
8012
+ },
8013
+ {
8014
+ type: "RGBICWW Floor Lamp",
8015
+ category: "physical",
8016
+ description: "RGBICWW floor lamp with 0-100 brightness, RGB color, and color temperature control.",
8017
+ role: "lighting",
8018
+ commands: [...onOffToggle, ...rgbLightControls0To100],
8019
+ statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
8020
+ },
8021
+ {
8022
+ type: "RGBIC Neon Wire Rope Light",
8023
+ category: "physical",
8024
+ description: "RGBIC neon wire rope light with 0-100 brightness and RGB color control.",
8025
+ role: "lighting",
8026
+ commands: [...onOffToggle, ...rgbOnlyLightControls0To100],
8027
+ statusFields: ["power", "brightness", "color", "version"]
8028
+ },
8029
+ {
8030
+ type: "RGBIC Neon Rope Light",
8031
+ category: "physical",
8032
+ description: "RGBIC neon rope light with 0-100 brightness and RGB color control.",
8033
+ role: "lighting",
8034
+ commands: [...onOffToggle, ...rgbOnlyLightControls0To100],
8035
+ statusFields: ["power", "brightness", "color", "version"]
8036
+ },
7966
8037
  {
7967
8038
  type: "Ceiling Light",
7968
8039
  category: "physical",
@@ -7984,7 +8055,7 @@ var init_catalog = __esm({
7984
8055
  commands: [
7985
8056
  ...onOff,
7986
8057
  { command: "setMode", parameter: "0=schedule 1=manual 2=off 3=eco 4=comfort 5=quickHeat", description: "Operating mode", idempotent: true, exampleParams: ["1", "3"] },
7987
- { command: "setManualModeTemperature", parameter: "5-30 (\xB0C)", description: "Target temperature in manual mode", idempotent: true, exampleParams: ["20", "22"] }
8058
+ { command: "setManualModeTemperature", parameter: "4-35 (\xB0C)", description: "Target temperature in manual mode", idempotent: true, exampleParams: ["20", "22"] }
7988
8059
  ],
7989
8060
  statusFields: ["power", "temperature", "humidity", "battery", "version", "mode", "targetTemperature"]
7990
8061
  },
@@ -7993,7 +8064,7 @@ var init_catalog = __esm({
7993
8064
  category: "physical",
7994
8065
  description: "Entry-level robot vacuum with start/stop/dock and four suction power levels.",
7995
8066
  role: "cleaning",
7996
- aliases: ["Robot Vacuum", "Robot Vacuum Cleaner S1 Plus", "K10+"],
8067
+ aliases: ["Robot Vacuum", "Robot Vacuum Cleaner S1 Plus", "K10+", "K10+ Pro"],
7997
8068
  commands: [
7998
8069
  { command: "start", parameter: "\u2014", description: "Start cleaning", idempotent: true },
7999
8070
  { command: "stop", parameter: "\u2014", description: "Stop cleaning", idempotent: true },
@@ -8003,17 +8074,17 @@ var init_catalog = __esm({
8003
8074
  statusFields: ["workingStatus", "onlineStatus", "battery", "version"]
8004
8075
  },
8005
8076
  {
8006
- type: "K10+ Pro Combo",
8077
+ type: "Robot Vacuum Cleaner K10+ Pro Combo",
8007
8078
  category: "physical",
8008
8079
  description: "Compact robot vacuum and mop combo with sweep/mop sessions, fan level, and water level.",
8009
8080
  role: "cleaning",
8010
- aliases: ["K20+ Pro"],
8081
+ aliases: ["K10+ Pro Combo", "K20+ Pro", "K11+", "Robot Vacuum Cleaner K11+"],
8011
8082
  commands: [
8012
8083
  { command: "startClean", parameter: `'{"action":"sweep"|"mop","param":{"fanLevel":1-4,"times":1-2639999}}'`, description: "Begin a cleaning session", idempotent: false, exampleParams: ['{"action":"sweep","param":{"fanLevel":2,"times":1}}'] },
8013
8084
  { command: "pause", parameter: "\u2014", description: "Pause cleaning", idempotent: true },
8014
8085
  { command: "dock", parameter: "\u2014", description: "Return to dock", idempotent: true },
8015
8086
  { command: "setVolume", parameter: "0-100", description: "Set voice volume", idempotent: true, exampleParams: ["0", "50", "100"] },
8016
- { command: "changeParam", parameter: `'{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}'`, description: "Change parameters mid-run", idempotent: true, exampleParams: ['{"fanLevel":3,"waterLevel":1,"times":1}'] }
8087
+ { command: "changeParam", parameter: `'{"fanLevel":1-4,"times":1-2639999}'`, description: "Change parameters mid-run", idempotent: true, exampleParams: ['{"fanLevel":3,"times":1}'] }
8017
8088
  ],
8018
8089
  statusFields: ["workingStatus", "onlineStatus", "battery", "taskType"]
8019
8090
  },
@@ -8022,7 +8093,7 @@ var init_catalog = __esm({
8022
8093
  category: "physical",
8023
8094
  description: "Advanced floor cleaning robot with sweep/mop modes, self-wash dock, and humidifier refill.",
8024
8095
  role: "cleaning",
8025
- aliases: ["Robot Vacuum Cleaner S10", "Robot Vacuum Cleaner S20"],
8096
+ aliases: ["Robot Vacuum Cleaner S10", "Robot Vacuum Cleaner S20", "S20"],
8026
8097
  commands: [
8027
8098
  { command: "startClean", parameter: `'{"action":"sweep"|"sweep_mop","param":{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}}'`, description: "Begin a cleaning session", idempotent: false, exampleParams: ['{"action":"sweep","param":{"fanLevel":2,"waterLevel":1,"times":1}}'] },
8028
8099
  { command: "pause", parameter: "\u2014", description: "Pause cleaning", idempotent: true },
@@ -8039,7 +8110,7 @@ var init_catalog = __esm({
8039
8110
  category: "physical",
8040
8111
  description: "Rechargeable table/floor fan with wind modes, speed control, night-light, and auto-off timer.",
8041
8112
  role: "fan",
8042
- aliases: ["Circulator Fan"],
8113
+ aliases: ["Circulator Fan", "Standing Circulator Fan"],
8043
8114
  commands: [
8044
8115
  ...onOffToggle,
8045
8116
  { command: "setNightLightMode", parameter: "off | 1 | 2", description: "Night-light mode", idempotent: true, exampleParams: ["off", "1", "2"] },
@@ -8101,7 +8172,7 @@ var init_catalog = __esm({
8101
8172
  category: "physical",
8102
8173
  description: "PIN-pad access controller that creates and deletes door passcodes for a Smart Lock.",
8103
8174
  role: "security",
8104
- aliases: ["Keypad Touch"],
8175
+ aliases: ["Keypad Touch", "Keypad Vision", "Keypad Vision Pro"],
8105
8176
  commands: [
8106
8177
  { command: "createKey", parameter: `'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}'`, description: "Create a passcode (async; result via webhook)", idempotent: false, safetyTier: "destructive", safetyReason: "Provisions a new access credential \u2014 anyone with this passcode can unlock the door." },
8107
8178
  { command: "deleteKey", parameter: `'{"id":<passcode_id>}'`, description: "Delete a passcode (async; result via webhook)", idempotent: true, safetyTier: "destructive", safetyReason: "Permanently removes a passcode \u2014 the holder immediately loses door access." }
@@ -8111,15 +8182,25 @@ var init_catalog = __esm({
8111
8182
  {
8112
8183
  type: "Candle Warmer Lamp",
8113
8184
  category: "physical",
8114
- description: "Decorative candle-warmer lamp with adjustable brightness and color temperature.",
8185
+ description: "Decorative candle-warmer lamp with adjustable 0-100 brightness.",
8115
8186
  role: "lighting",
8116
8187
  commands: [
8117
8188
  ...onOffToggle,
8118
- { command: "setBrightness", parameter: "1-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["50", "80"] },
8119
- { command: "setColorTemperature", parameter: "2700-6500", description: "Set color temperature (Kelvin)", idempotent: true, exampleParams: ["2700", "4000"] }
8189
+ { command: "setBrightness", parameter: "0-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["0", "50", "100"] }
8120
8190
  ],
8121
8191
  statusFields: ["power", "brightness", "colorTemperature", "version"]
8122
8192
  },
8193
+ {
8194
+ type: "AI Art Frame",
8195
+ category: "physical",
8196
+ description: "Digital art frame that can switch to the next or previous image.",
8197
+ role: "other",
8198
+ commands: [
8199
+ { command: "next", parameter: "\u2014", description: "Switch to the next image", idempotent: false },
8200
+ { command: "previous", parameter: "\u2014", description: "Switch to the previous image", idempotent: false }
8201
+ ],
8202
+ statusFields: ["version"]
8203
+ },
8123
8204
  // Status-only devices (no commands)
8124
8205
  {
8125
8206
  type: "Meter",
@@ -8155,6 +8236,7 @@ var init_catalog = __esm({
8155
8236
  description: "Water sensor that reports leak status; read-only, no control commands.",
8156
8237
  role: "sensor",
8157
8238
  readOnly: true,
8239
+ aliases: ["Water Detector"],
8158
8240
  commands: [],
8159
8241
  statusFields: ["battery", "version", "status"]
8160
8242
  },
@@ -31885,6 +31967,7 @@ var CSS_COLORS = {
31885
31967
  };
31886
31968
 
31887
31969
  // src/devices/param-validator.ts
31970
+ init_catalog();
31888
31971
  var AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
31889
31972
  var AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
31890
31973
  var CURTAIN_MODE_MAP = { default: "ff", performance: "0", silent: "1" };
@@ -31936,6 +32019,9 @@ function buildBlindTiltSetPosition(opts) {
31936
32019
  if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
31937
32020
  throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
31938
32021
  }
32022
+ if (angle % 2 !== 0) {
32023
+ throw new UsageError(`--angle must be a multiple of 2 (got "${opts.angle}"). Example: --angle 50`);
32024
+ }
31939
32025
  return `${dir};${angle}`;
31940
32026
  }
31941
32027
  function buildRelaySetMode(opts) {
@@ -31951,11 +32037,12 @@ function buildRelaySetMode(opts) {
31951
32037
  }
31952
32038
  return `${ch};${modeInt}`;
31953
32039
  }
31954
- function buildBrightnessSet(opts) {
31955
- if (!opts.brightness) throw new UsageError("--brightness is required (1-100)");
32040
+ function buildBrightnessSet(opts, deviceType) {
32041
+ const [min, max] = deviceType && brightnessRange(deviceType) || [1, 100];
32042
+ if (!opts.brightness) throw new UsageError(`--brightness is required (${min}-${max})`);
31956
32043
  const b2 = parseInt(opts.brightness, 10);
31957
- if (!Number.isFinite(b2) || b2 < 1 || b2 > 100) {
31958
- throw new UsageError(`--brightness must be an integer between 1 and 100 (got "${opts.brightness}")`);
32044
+ if (!Number.isFinite(b2) || b2 < min || b2 > max) {
32045
+ throw new UsageError(`--brightness must be an integer between ${min} and ${max} (got "${opts.brightness}")`);
31959
32046
  }
31960
32047
  return String(b2);
31961
32048
  }
@@ -31971,67 +32058,187 @@ function buildColorTemperatureSet(opts) {
31971
32058
  if (!result.ok) throw new UsageError(result.error);
31972
32059
  return result.normalized ?? opts.colorTemp;
31973
32060
  }
32061
+ function parseParameterForWire(parameter) {
32062
+ if (parameter === void 0) return "default";
32063
+ const trimmed = parameter.trim();
32064
+ if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
32065
+ try {
32066
+ return JSON.parse(parameter);
32067
+ } catch {
32068
+ return parameter;
32069
+ }
32070
+ }
32071
+ return parameter;
32072
+ }
31974
32073
  function validateParameter(deviceType, command, raw) {
31975
32074
  if (!deviceType) return { ok: true };
31976
- if (deviceType === "Air Conditioner" && command === "setAll") {
32075
+ const dt = canonicalizeDeviceType(deviceType);
32076
+ if (dt === "Air Conditioner" && command === "setAll") {
31977
32077
  return validateAcSetAll(raw);
31978
32078
  }
31979
- if (deviceType.startsWith("Curtain") && command === "setPosition") {
32079
+ if (dt.startsWith("Curtain") && command === "setPosition") {
31980
32080
  return validateCurtainSetPosition(raw);
31981
32081
  }
31982
- if (deviceType.startsWith("Blind Tilt") && command === "setPosition") {
32082
+ if (dt.startsWith("Blind Tilt") && command === "setPosition") {
31983
32083
  return validateBlindTiltSetPosition(raw);
31984
32084
  }
31985
- if (deviceType.startsWith("Relay Switch") && command === "setMode") {
31986
- return validateRelaySetMode(raw);
32085
+ if ((dt === "Relay Switch 1" || dt === "Relay Switch 1PM") && command === "setMode") {
32086
+ return validateIntRange(raw, "setMode", 0, 3, "Relay Switch mode (0=toggle 1=edge 2=detached 3=momentary)");
32087
+ }
32088
+ if (dt === "Relay Switch 2PM" && command === "setMode") {
32089
+ return validateRelay2PmSetMode(raw);
32090
+ }
32091
+ if (dt === "Relay Switch 2PM" && (command === "turnOn" || command === "turnOff" || command === "toggle")) {
32092
+ return validateRelayChannel(raw);
32093
+ }
32094
+ if (dt === "Relay Switch 2PM" && command === "setPosition") {
32095
+ return validateIntRange(raw, "setPosition", 0, 100, "Relay Switch 2PM roller-shade percentage");
31987
32096
  }
31988
- if (command === "setBrightness" && isBrightnessDevice(deviceType)) {
31989
- return validateSetBrightness(raw);
32097
+ if (command === "setBrightness" && isBrightnessDevice(dt)) {
32098
+ return validateSetBrightness(raw, dt);
31990
32099
  }
31991
- if (command === "setColor" && isColorDevice(deviceType)) {
32100
+ if (command === "setColor" && isColorDevice(dt)) {
31992
32101
  return validateSetColor(raw);
31993
32102
  }
31994
- if (command === "setColorTemperature" && isBrightnessDevice(deviceType)) {
32103
+ if (command === "setColorTemperature" && isColorTemperatureDevice(dt)) {
31995
32104
  return validateSetColorTemperature(raw);
31996
32105
  }
32106
+ if (dt === "Humidifier" && command === "setMode") {
32107
+ return validateHumidifierSetMode(raw);
32108
+ }
32109
+ if (dt === "Humidifier2" && command === "setMode") {
32110
+ return validateHumidifier2SetMode(raw);
32111
+ }
32112
+ if (dt === "Humidifier2" && command === "setChildLock") {
32113
+ return validateEnum(raw, "setChildLock", ["true", "false"]);
32114
+ }
32115
+ if (isAirPurifierDevice(dt) && command === "setMode") {
32116
+ return validateAirPurifierSetMode(raw);
32117
+ }
32118
+ if (isAirPurifierDevice(dt) && command === "setChildLock") {
32119
+ return validateEnum(raw, "setChildLock", ["0", "1"]);
32120
+ }
32121
+ if (isPowLevelVacuum(dt) && command === "PowLevel") {
32122
+ return validateIntRange(raw, "PowLevel", 0, 3, "suction level (0=Quiet 1=Standard 2=Strong 3=Max)");
32123
+ }
32124
+ if ((isComboVacuum(dt) || isFloorCleaningVacuum(dt)) && command === "startClean") {
32125
+ return validateVacuumStartClean(raw, dt);
32126
+ }
32127
+ if ((isComboVacuum(dt) || isFloorCleaningVacuum(dt)) && command === "setVolume") {
32128
+ return validateIntRange(raw, "setVolume", 0, 100, "volume percentage");
32129
+ }
32130
+ if ((isComboVacuum(dt) || isFloorCleaningVacuum(dt)) && command === "changeParam") {
32131
+ return validateVacuumChangeParam(raw, dt);
32132
+ }
32133
+ if (isFloorCleaningVacuum(dt) && command === "selfClean") {
32134
+ return validateEnum(raw, "selfClean", ["1", "2", "3"], "1=wash mop, 2=dry, 3=terminate");
32135
+ }
32136
+ if (isCirculatorFan(dt) && command === "setNightLightMode") {
32137
+ return validateEnum(raw, "setNightLightMode", ["off", "1", "2"]);
32138
+ }
32139
+ if (isCirculatorFan(dt) && command === "setWindMode") {
32140
+ return validateEnum(raw, "setWindMode", ["direct", "natural", "sleep", "baby"]);
32141
+ }
32142
+ if (isCirculatorFan(dt) && command === "setWindSpeed") {
32143
+ return validateIntRange(raw, "setWindSpeed", 1, 100, "fan speed percentage");
32144
+ }
32145
+ if (isCirculatorFan(dt) && command === "closeDelay") {
32146
+ return validateIntRange(raw, "closeDelay", 1, 36e3, "auto-off delay in seconds");
32147
+ }
32148
+ if (dt === "Smart Radiator Thermostat" && command === "setMode") {
32149
+ return validateIntRange(raw, "setMode", 0, 5, "mode (0=schedule 1=manual 2=off 3=eco 4=comfort 5=quickHeat)");
32150
+ }
32151
+ if (dt === "Smart Radiator Thermostat" && command === "setManualModeTemperature") {
32152
+ return validateIntRange(raw, "setManualModeTemperature", 4, 35, "temperature in \xB0C");
32153
+ }
32154
+ if (dt.startsWith("Keypad") && command === "createKey") {
32155
+ return validateKeypadCreateKey(raw);
32156
+ }
32157
+ if (dt.startsWith("Keypad") && command === "deleteKey") {
32158
+ return validateKeypadDeleteKey(raw);
32159
+ }
32160
+ if (dt === "TV" && command === "SetChannel") {
32161
+ return validateIntRange(raw, "SetChannel", 1, 999, "channel number");
32162
+ }
32163
+ if (dt === "Roller Shade" && command === "setPosition") {
32164
+ return validateIntRange(raw, "setPosition", 0, 100, "position percentage (0=open, 100=closed)");
32165
+ }
31997
32166
  return { ok: true };
31998
32167
  }
31999
32168
  function isBrightnessDevice(deviceType) {
32000
- return deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Strip Light 3" || deviceType === "Ceiling Light" || deviceType === "Ceiling Light Pro" || deviceType === "Floor Lamp" || deviceType === "Light Strip" || deviceType === "Dimmer" || deviceType === "Fill Light";
32169
+ return brightnessRange(deviceType) !== null;
32170
+ }
32171
+ function brightnessRange(deviceType) {
32172
+ if (deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Ceiling Light" || deviceType === "Ceiling Light Pro") {
32173
+ return [1, 100];
32174
+ }
32175
+ if (deviceType === "Floor Lamp" || deviceType === "Strip Light 3" || deviceType === "RGBICWW Strip Light" || deviceType === "RGBICWW Floor Lamp" || deviceType === "RGBIC Neon Wire Rope Light" || deviceType === "RGBIC Neon Rope Light" || deviceType === "Candle Warmer Lamp") {
32176
+ return [0, 100];
32177
+ }
32178
+ if (deviceType === "Light Strip" || deviceType === "Dimmer" || deviceType === "Fill Light") {
32179
+ return [1, 100];
32180
+ }
32181
+ return null;
32001
32182
  }
32002
32183
  function isColorDevice(deviceType) {
32003
- return deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Strip Light 3" || deviceType === "Floor Lamp" || deviceType === "Light Strip" || deviceType === "Fill Light";
32184
+ return deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Strip Light 3" || deviceType === "Floor Lamp" || deviceType === "RGBICWW Strip Light" || deviceType === "RGBICWW Floor Lamp" || deviceType === "RGBIC Neon Wire Rope Light" || deviceType === "RGBIC Neon Rope Light" || deviceType === "Light Strip" || deviceType === "Fill Light";
32185
+ }
32186
+ function isColorTemperatureDevice(deviceType) {
32187
+ return deviceType === "Color Bulb" || deviceType === "Floor Lamp" || deviceType === "Strip Light 3" || deviceType === "Ceiling Light" || deviceType === "Ceiling Light Pro" || deviceType === "RGBICWW Strip Light" || deviceType === "RGBICWW Floor Lamp" || deviceType === "Light Strip" || deviceType === "Dimmer" || deviceType === "Fill Light";
32188
+ }
32189
+ function isAirPurifierDevice(deviceType) {
32190
+ return deviceType === "Air Purifier VOC" || deviceType === "Air Purifier Table VOC" || deviceType === "Air Purifier PM2.5" || deviceType === "Air Purifier Table PM2.5";
32191
+ }
32192
+ function isPowLevelVacuum(deviceType) {
32193
+ return deviceType === "Robot Vacuum Cleaner S1" || deviceType === "Robot Vacuum Cleaner S1 Plus" || deviceType === "K10+" || deviceType === "K10+ Pro";
32194
+ }
32195
+ function isComboVacuum(deviceType) {
32196
+ return deviceType === "K10+ Pro Combo" || deviceType === "Robot Vacuum Cleaner K10+ Pro Combo" || deviceType === "K20+ Pro" || deviceType === "K11+" || deviceType === "Robot Vacuum Cleaner K11+";
32197
+ }
32198
+ function isFloorCleaningVacuum(deviceType) {
32199
+ return deviceType === "Floor Cleaning Robot S10" || deviceType === "S20" || deviceType === "Robot Vacuum Cleaner S20";
32200
+ }
32201
+ function isCirculatorFan(deviceType) {
32202
+ return deviceType === "Battery Circulator Fan" || deviceType === "Circulator Fan" || deviceType === "Standing Circulator Fan";
32004
32203
  }
32005
32204
  function isLightingCommandSupported(deviceType, command) {
32006
- if (command === "setBrightness" || command === "setColorTemperature") return isBrightnessDevice(deviceType);
32007
- if (command === "setColor") return isColorDevice(deviceType);
32205
+ const dt = canonicalizeDeviceType(deviceType);
32206
+ if (command === "setBrightness") return isBrightnessDevice(dt);
32207
+ if (command === "setColorTemperature") return isColorTemperatureDevice(dt);
32208
+ if (command === "setColor") return isColorDevice(dt);
32209
+ return false;
32210
+ }
32211
+ function isNumericish(v2) {
32212
+ if (typeof v2 === "number") return true;
32213
+ if (typeof v2 === "string" && v2.trim() !== "") return true;
32008
32214
  return false;
32009
32215
  }
32010
- function validateSetBrightness(raw) {
32216
+ function validateSetBrightness(raw, deviceType) {
32217
+ const [min, max] = brightnessRange(deviceType) ?? [1, 100];
32011
32218
  if (raw === void 0 || raw === "" || raw === "default") {
32012
32219
  return {
32013
32220
  ok: false,
32014
- error: `setBrightness requires an integer 1-100 (percent). Example: "50".`
32221
+ error: `setBrightness requires an integer ${min}-${max} (percent). Example: "50".`
32015
32222
  };
32016
32223
  }
32017
- const trimmed = raw.trim();
32224
+ const trimmed = stripQuotes(raw.trim());
32018
32225
  if (!/^-?\d+$/.test(trimmed)) {
32019
32226
  return {
32020
32227
  ok: false,
32021
- error: `setBrightness must be an integer 1-100, got ${JSON.stringify(raw)}. ${hintBrightnessRetry()}`
32228
+ error: `setBrightness must be an integer ${min}-${max}, got ${JSON.stringify(raw)}. ${hintBrightnessRetry(min, max)}`
32022
32229
  };
32023
32230
  }
32024
32231
  const n = Number(trimmed);
32025
- if (!Number.isInteger(n) || n < 1 || n > 100) {
32232
+ if (!Number.isInteger(n) || n < min || n > max) {
32026
32233
  return {
32027
32234
  ok: false,
32028
- error: `setBrightness must be an integer 1-100, got "${raw}". ${hintBrightnessRetry()}`
32235
+ error: `setBrightness must be an integer ${min}-${max}, got "${raw}". ${hintBrightnessRetry(min, max)}`
32029
32236
  };
32030
32237
  }
32031
32238
  return { ok: true, normalized: String(n) };
32032
32239
  }
32033
- function hintBrightnessRetry() {
32034
- return `Ask the user whether they meant a percentage (1-100). Example: "50".`;
32240
+ function hintBrightnessRetry(min = 1, max = 100) {
32241
+ return `Ask the user whether they meant a percentage (${min}-${max}). Example: "50".`;
32035
32242
  }
32036
32243
  var CUSTOM_COLORS = {
32037
32244
  warm: [255, 180, 100]
@@ -32047,7 +32254,7 @@ function validateSetColor(raw) {
32047
32254
  error: `setColor requires a color. Use a CSS color name (e.g. coral, teal, salmon), hex (#RRGGBB / #RGB), or R:G:B format.`
32048
32255
  };
32049
32256
  }
32050
- const trimmed = raw.trim();
32257
+ const trimmed = stripQuotes(raw.trim());
32051
32258
  const named = NAMED_COLORS[trimmed.toLowerCase()];
32052
32259
  if (named) {
32053
32260
  return { ok: true, normalized: `${named[0]}:${named[1]}:${named[2]}` };
@@ -32114,7 +32321,7 @@ function validateSetColorTemperature(raw) {
32114
32321
  error: `setColorTemperature requires an integer Kelvin value 2700-6500. Example: "4000".`
32115
32322
  };
32116
32323
  }
32117
- const trimmed = raw.trim();
32324
+ const trimmed = stripQuotes(raw.trim());
32118
32325
  if (!/^-?\d+$/.test(trimmed)) {
32119
32326
  return {
32120
32327
  ok: false,
@@ -32137,13 +32344,14 @@ function validateAcSetAll(raw) {
32137
32344
  error: `setAll requires a parameter "<temp>,<mode>,<fan>,<on|off>". Example: "26,2,2,on".`
32138
32345
  };
32139
32346
  }
32140
- if (raw.startsWith("{") || raw.startsWith("[")) {
32347
+ const stripped = stripQuotes(raw.trim());
32348
+ if (stripped.startsWith("{") || stripped.startsWith("[")) {
32141
32349
  return {
32142
32350
  ok: false,
32143
32351
  error: `setAll parameter must be a CSV string like "26,2,2,on", not JSON (got ${JSON.stringify(raw)}).`
32144
32352
  };
32145
32353
  }
32146
- const parts = raw.split(",");
32354
+ const parts = stripped.split(",");
32147
32355
  if (parts.length !== 4) {
32148
32356
  return {
32149
32357
  ok: false,
@@ -32188,8 +32396,9 @@ function validateCurtainSetPosition(raw) {
32188
32396
  error: `setPosition requires a parameter. Expected: "<0-100>" or "<index>,<ff|0|1>,<0-100>". Example: "50" or "0,ff,50".`
32189
32397
  };
32190
32398
  }
32191
- if (!raw.includes(",")) {
32192
- const pos2 = Number(raw);
32399
+ const stripped = stripQuotes(raw.trim());
32400
+ if (!stripped.includes(",")) {
32401
+ const pos2 = Number(stripped);
32193
32402
  if (!Number.isInteger(pos2) || pos2 < 0 || pos2 > 100) {
32194
32403
  return {
32195
32404
  ok: false,
@@ -32198,7 +32407,7 @@ function validateCurtainSetPosition(raw) {
32198
32407
  }
32199
32408
  return { ok: true, normalized: String(pos2) };
32200
32409
  }
32201
- const parts = raw.split(",").map((s2) => s2.trim());
32410
+ const parts = stripped.split(",").map((s2) => s2.trim());
32202
32411
  if (parts.length !== 3) {
32203
32412
  return {
32204
32413
  ok: false,
@@ -32236,7 +32445,8 @@ function validateBlindTiltSetPosition(raw) {
32236
32445
  error: `Blind Tilt setPosition requires a parameter. Expected: "<up|down>;<0-100>". Example: "up;50".`
32237
32446
  };
32238
32447
  }
32239
- const parts = raw.split(";");
32448
+ const stripped = stripQuotes(raw.trim());
32449
+ const parts = stripped.split(";");
32240
32450
  if (parts.length !== 2) {
32241
32451
  return {
32242
32452
  ok: false,
@@ -32257,16 +32467,23 @@ function validateBlindTiltSetPosition(raw) {
32257
32467
  error: `Blind Tilt setPosition angle must be an integer 0-100, got "${parts[1]}".`
32258
32468
  };
32259
32469
  }
32470
+ if (angle % 2 !== 0) {
32471
+ return {
32472
+ ok: false,
32473
+ error: `Blind Tilt setPosition angle must be a multiple of 2, got "${parts[1]}". Example: "up;48".`
32474
+ };
32475
+ }
32260
32476
  return { ok: true, normalized: `${dir};${angle}` };
32261
32477
  }
32262
- function validateRelaySetMode(raw) {
32478
+ function validateRelay2PmSetMode(raw) {
32263
32479
  if (raw === void 0 || raw === "" || raw === "default") {
32264
32480
  return {
32265
32481
  ok: false,
32266
32482
  error: `Relay Switch setMode requires a parameter. Expected: "<1|2>;<0|1|2|3>". Example: "1;1" (channel 1, edge mode).`
32267
32483
  };
32268
32484
  }
32269
- const parts = raw.split(";");
32485
+ const stripped = stripQuotes(raw.trim());
32486
+ const parts = stripped.split(";");
32270
32487
  if (parts.length !== 2) {
32271
32488
  return {
32272
32489
  ok: false,
@@ -32289,6 +32506,342 @@ function validateRelaySetMode(raw) {
32289
32506
  }
32290
32507
  return { ok: true, normalized: `${ch};${mode}` };
32291
32508
  }
32509
+ function validateRelayChannel(raw) {
32510
+ if (raw === void 0 || raw === "" || raw === "default") {
32511
+ return {
32512
+ ok: false,
32513
+ error: `Relay Switch 2PM turnOn/turnOff/toggle requires a channel parameter: "1" or "2". Example: turnOff 1`
32514
+ };
32515
+ }
32516
+ const n = stripQuotes(raw.trim());
32517
+ if (n !== "1" && n !== "2") {
32518
+ return {
32519
+ ok: false,
32520
+ error: `Relay Switch 2PM channel must be "1" or "2", got ${JSON.stringify(raw)}.`
32521
+ };
32522
+ }
32523
+ return { ok: true, normalized: n };
32524
+ }
32525
+ function stripQuotes(s2) {
32526
+ if (s2.length >= 2 && s2.startsWith('"') && s2.endsWith('"')) {
32527
+ return s2.slice(1, -1);
32528
+ }
32529
+ return s2;
32530
+ }
32531
+ function validateIntRange(raw, command, min, max, label) {
32532
+ if (raw === void 0 || raw === "" || raw === "default") {
32533
+ return {
32534
+ ok: false,
32535
+ error: `${command} requires an integer ${min}-${max} (${label}). Example: "${Math.round((min + max) / 2)}".`
32536
+ };
32537
+ }
32538
+ const trimmed = stripQuotes(raw.trim());
32539
+ if (!/^-?\d+$/.test(trimmed)) {
32540
+ return {
32541
+ ok: false,
32542
+ error: `${command} must be an integer ${min}-${max} (${label}), got ${JSON.stringify(raw)}.`
32543
+ };
32544
+ }
32545
+ const n = Number(trimmed);
32546
+ if (n < min || n > max) {
32547
+ return {
32548
+ ok: false,
32549
+ error: `${command} must be an integer ${min}-${max} (${label}), got ${n}.`
32550
+ };
32551
+ }
32552
+ return { ok: true, normalized: String(n) };
32553
+ }
32554
+ function validateEnum(raw, command, allowed, hint) {
32555
+ if (raw === void 0 || raw === "" || raw === "default") {
32556
+ return {
32557
+ ok: false,
32558
+ error: `${command} requires a parameter: ${allowed.join(" | ")}${hint ? ` (${hint})` : ""}. Example: "${allowed[0]}".`
32559
+ };
32560
+ }
32561
+ const trimmed = stripQuotes(raw.trim()).toLowerCase();
32562
+ const match = allowed.find((a) => a.toLowerCase() === trimmed);
32563
+ if (!match) {
32564
+ return {
32565
+ ok: false,
32566
+ error: `${command} must be one of: ${allowed.join(", ")}. Got ${JSON.stringify(raw)}.`
32567
+ };
32568
+ }
32569
+ return { ok: true, normalized: match };
32570
+ }
32571
+ function validateHumidifierSetMode(raw) {
32572
+ if (raw === void 0 || raw === "" || raw === "default") {
32573
+ return {
32574
+ ok: false,
32575
+ error: `Humidifier setMode requires a parameter: "auto", "101", "102", "103", or 0-100 (humidity %). Example: "auto".`
32576
+ };
32577
+ }
32578
+ const trimmed = stripQuotes(raw.trim()).toLowerCase();
32579
+ if (trimmed === "auto") return { ok: true, normalized: "auto" };
32580
+ if (["101", "102", "103"].includes(trimmed)) return { ok: true, normalized: trimmed };
32581
+ if (/^\d+$/.test(trimmed)) {
32582
+ const n = Number(trimmed);
32583
+ if (n >= 0 && n <= 100) return { ok: true, normalized: String(n) };
32584
+ }
32585
+ return {
32586
+ ok: false,
32587
+ error: `Humidifier setMode must be "auto", "101" (34%), "102" (67%), "103" (100%), or 0-100. Got ${JSON.stringify(raw)}.`
32588
+ };
32589
+ }
32590
+ function validateHumidifier2SetMode(raw) {
32591
+ if (raw === void 0 || raw === "" || raw === "default") {
32592
+ return {
32593
+ ok: false,
32594
+ error: `Humidifier2 setMode requires a JSON parameter: {"mode":1-8,"targetHumidify":0-100}. Example: '{"mode":7,"targetHumidify":50}'.`
32595
+ };
32596
+ }
32597
+ let obj;
32598
+ try {
32599
+ obj = JSON.parse(raw);
32600
+ } catch {
32601
+ return {
32602
+ ok: false,
32603
+ error: `Humidifier2 setMode expects JSON: {"mode":1-8,"targetHumidify":0-100}. Got ${JSON.stringify(raw)}.`
32604
+ };
32605
+ }
32606
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32607
+ return { ok: false, error: `Humidifier2 setMode expects a JSON object, got ${typeof obj}.` };
32608
+ }
32609
+ const o = obj;
32610
+ if (!isNumericish(o.mode)) {
32611
+ return { ok: false, error: `Humidifier2 setMode "mode" must be a number or numeric string, got ${JSON.stringify(o.mode)}.` };
32612
+ }
32613
+ const mode = Number(o.mode);
32614
+ if (!Number.isInteger(mode) || mode < 1 || mode > 8) {
32615
+ return { ok: false, error: `Humidifier2 setMode "mode" must be 1-8, got ${JSON.stringify(o.mode)}.` };
32616
+ }
32617
+ if (!isNumericish(o.targetHumidify)) {
32618
+ return { ok: false, error: `Humidifier2 setMode "targetHumidify" must be a number or numeric string, got ${JSON.stringify(o.targetHumidify)}.` };
32619
+ }
32620
+ const hum = Number(o.targetHumidify);
32621
+ if (!Number.isInteger(hum) || hum < 0 || hum > 100) {
32622
+ return { ok: false, error: `Humidifier2 setMode "targetHumidify" must be 0-100, got ${JSON.stringify(o.targetHumidify)}.` };
32623
+ }
32624
+ return { ok: true, normalized: JSON.stringify({ mode, targetHumidify: hum }) };
32625
+ }
32626
+ function validateAirPurifierSetMode(raw) {
32627
+ if (raw === void 0 || raw === "" || raw === "default") {
32628
+ return {
32629
+ ok: false,
32630
+ error: `Air Purifier setMode requires a JSON parameter: {"mode":1-4} or {"mode":1,"fanGear":1-3}. Example: '{"mode":2}'.`
32631
+ };
32632
+ }
32633
+ let obj;
32634
+ try {
32635
+ obj = JSON.parse(raw);
32636
+ } catch {
32637
+ return {
32638
+ ok: false,
32639
+ error: `Air Purifier setMode expects JSON: {"mode":1-4,"fanGear":1-3}. Got ${JSON.stringify(raw)}.`
32640
+ };
32641
+ }
32642
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32643
+ return { ok: false, error: `Air Purifier setMode expects a JSON object, got ${typeof obj}.` };
32644
+ }
32645
+ const o = obj;
32646
+ if (!isNumericish(o.mode)) {
32647
+ return { ok: false, error: `Air Purifier setMode "mode" must be a number or numeric string, got ${JSON.stringify(o.mode)}.` };
32648
+ }
32649
+ const mode = Number(o.mode);
32650
+ if (!Number.isInteger(mode) || mode < 1 || mode > 4) {
32651
+ return { ok: false, error: `Air Purifier setMode "mode" must be 1-4 (1=normal 2=auto 3=sleep 4=pet), got ${JSON.stringify(o.mode)}.` };
32652
+ }
32653
+ const normalized = { mode };
32654
+ if (o.fanGear !== void 0) {
32655
+ if (mode !== 1) {
32656
+ return { ok: false, error: `Air Purifier setMode "fanGear" can only be set when "mode" is 1 (normal/fan mode).` };
32657
+ }
32658
+ if (!isNumericish(o.fanGear)) {
32659
+ return { ok: false, error: `Air Purifier setMode "fanGear" must be a number or numeric string, got ${JSON.stringify(o.fanGear)}.` };
32660
+ }
32661
+ const fg = Number(o.fanGear);
32662
+ if (!Number.isInteger(fg) || fg < 1 || fg > 3) {
32663
+ return { ok: false, error: `Air Purifier setMode "fanGear" must be 1-3, got ${JSON.stringify(o.fanGear)}.` };
32664
+ }
32665
+ normalized.fanGear = fg;
32666
+ }
32667
+ return { ok: true, normalized: JSON.stringify(normalized) };
32668
+ }
32669
+ function validateVacuumStartClean(raw, deviceType) {
32670
+ if (raw === void 0 || raw === "" || raw === "default") {
32671
+ const actions = isFloorCleaningVacuum(deviceType) ? "sweep | sweep_mop" : "sweep | mop";
32672
+ return {
32673
+ ok: false,
32674
+ error: `${deviceType} startClean requires a JSON parameter: {"action":"${actions.split(" | ")[0]}","param":{"fanLevel":1-4,"times":1}}. Example: '{"action":"${actions.split(" | ")[0]}","param":{"fanLevel":2,"times":1}}'.`
32675
+ };
32676
+ }
32677
+ let obj;
32678
+ try {
32679
+ obj = JSON.parse(raw);
32680
+ } catch {
32681
+ return {
32682
+ ok: false,
32683
+ error: `${deviceType} startClean expects JSON. Got ${JSON.stringify(raw)}.`
32684
+ };
32685
+ }
32686
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32687
+ return { ok: false, error: `${deviceType} startClean expects a JSON object.` };
32688
+ }
32689
+ const o = obj;
32690
+ const validActions = isFloorCleaningVacuum(deviceType) ? ["sweep", "sweep_mop"] : ["sweep", "mop"];
32691
+ if (typeof o.action !== "string" || !validActions.includes(o.action)) {
32692
+ return { ok: false, error: `${deviceType} startClean "action" must be one of: ${validActions.join(", ")}. Got ${JSON.stringify(o.action)}.` };
32693
+ }
32694
+ const normalized = { action: o.action };
32695
+ if (o.param !== void 0) {
32696
+ if (typeof o.param !== "object" || o.param === null || Array.isArray(o.param)) {
32697
+ return { ok: false, error: `${deviceType} startClean "param" must be an object.` };
32698
+ }
32699
+ const p2 = o.param;
32700
+ const normalizedParam = {};
32701
+ if (p2.fanLevel !== void 0) {
32702
+ if (!isNumericish(p2.fanLevel)) {
32703
+ return { ok: false, error: `${deviceType} startClean "param.fanLevel" must be a number or numeric string, got ${JSON.stringify(p2.fanLevel)}.` };
32704
+ }
32705
+ const fl = Number(p2.fanLevel);
32706
+ if (!Number.isInteger(fl) || fl < 1 || fl > 4) {
32707
+ return { ok: false, error: `${deviceType} startClean "param.fanLevel" must be 1-4, got ${JSON.stringify(p2.fanLevel)}.` };
32708
+ }
32709
+ normalizedParam.fanLevel = fl;
32710
+ }
32711
+ if (p2.waterLevel !== void 0) {
32712
+ if (!isFloorCleaningVacuum(deviceType)) {
32713
+ return { ok: false, error: `${deviceType} startClean "param.waterLevel" is only supported for Floor Cleaning Robot S10/S20.` };
32714
+ }
32715
+ if (!isNumericish(p2.waterLevel)) {
32716
+ return { ok: false, error: `${deviceType} startClean "param.waterLevel" must be a number or numeric string, got ${JSON.stringify(p2.waterLevel)}.` };
32717
+ }
32718
+ const wl = Number(p2.waterLevel);
32719
+ if (!Number.isInteger(wl) || wl < 1 || wl > 2) {
32720
+ return { ok: false, error: `${deviceType} startClean "param.waterLevel" must be 1-2, got ${JSON.stringify(p2.waterLevel)}.` };
32721
+ }
32722
+ normalizedParam.waterLevel = wl;
32723
+ }
32724
+ if (p2.times !== void 0) {
32725
+ if (!isNumericish(p2.times)) {
32726
+ return { ok: false, error: `${deviceType} startClean "param.times" must be a number or numeric string, got ${JSON.stringify(p2.times)}.` };
32727
+ }
32728
+ const t = Number(p2.times);
32729
+ if (!Number.isInteger(t) || t < 1 || t > 2639999) {
32730
+ return { ok: false, error: `${deviceType} startClean "param.times" must be an integer 1-2639999, got ${JSON.stringify(p2.times)}.` };
32731
+ }
32732
+ normalizedParam.times = t;
32733
+ }
32734
+ normalized.param = normalizedParam;
32735
+ }
32736
+ return { ok: true, normalized: JSON.stringify(normalized) };
32737
+ }
32738
+ function validateVacuumChangeParam(raw, deviceType) {
32739
+ if (raw === void 0 || raw === "" || raw === "default") {
32740
+ return {
32741
+ ok: false,
32742
+ error: `changeParam requires a JSON parameter: {"fanLevel":1-4,"waterLevel":1-2,"times":1}. Example: '{"fanLevel":3,"waterLevel":1,"times":1}'.`
32743
+ };
32744
+ }
32745
+ let obj;
32746
+ try {
32747
+ obj = JSON.parse(raw);
32748
+ } catch {
32749
+ return {
32750
+ ok: false,
32751
+ error: `changeParam expects JSON: {"fanLevel":1-4,"waterLevel":1-2,"times":...}. Got ${JSON.stringify(raw)}.`
32752
+ };
32753
+ }
32754
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32755
+ return { ok: false, error: `changeParam expects a JSON object.` };
32756
+ }
32757
+ const p2 = obj;
32758
+ const normalized = {};
32759
+ if (p2.fanLevel !== void 0) {
32760
+ if (!isNumericish(p2.fanLevel)) {
32761
+ return { ok: false, error: `changeParam "fanLevel" must be a number or numeric string, got ${JSON.stringify(p2.fanLevel)}.` };
32762
+ }
32763
+ const fl = Number(p2.fanLevel);
32764
+ if (!Number.isInteger(fl) || fl < 1 || fl > 4) {
32765
+ return { ok: false, error: `changeParam "fanLevel" must be 1-4, got ${JSON.stringify(p2.fanLevel)}.` };
32766
+ }
32767
+ normalized.fanLevel = fl;
32768
+ }
32769
+ if (p2.waterLevel !== void 0) {
32770
+ if (isComboVacuum(deviceType)) {
32771
+ return { ok: false, error: `${deviceType} changeParam does not support "waterLevel" according to the API docs.` };
32772
+ }
32773
+ if (!isNumericish(p2.waterLevel)) {
32774
+ return { ok: false, error: `changeParam "waterLevel" must be a number or numeric string, got ${JSON.stringify(p2.waterLevel)}.` };
32775
+ }
32776
+ const wl = Number(p2.waterLevel);
32777
+ if (!Number.isInteger(wl) || wl < 1 || wl > 2) {
32778
+ return { ok: false, error: `changeParam "waterLevel" must be 1-2, got ${JSON.stringify(p2.waterLevel)}.` };
32779
+ }
32780
+ normalized.waterLevel = wl;
32781
+ }
32782
+ if (p2.times !== void 0) {
32783
+ if (!isNumericish(p2.times)) {
32784
+ return { ok: false, error: `changeParam "times" must be a number or numeric string, got ${JSON.stringify(p2.times)}.` };
32785
+ }
32786
+ const t = Number(p2.times);
32787
+ if (!Number.isInteger(t) || t < 1 || t > 2639999) {
32788
+ return { ok: false, error: `changeParam "times" must be an integer 1-2639999, got ${JSON.stringify(p2.times)}.` };
32789
+ }
32790
+ normalized.times = t;
32791
+ }
32792
+ return { ok: true, normalized: JSON.stringify(normalized) };
32793
+ }
32794
+ function validateKeypadCreateKey(raw) {
32795
+ if (raw === void 0 || raw === "" || raw === "default") {
32796
+ return {
32797
+ ok: false,
32798
+ error: `createKey requires a JSON parameter: {"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits",...}.`
32799
+ };
32800
+ }
32801
+ let obj;
32802
+ try {
32803
+ obj = JSON.parse(raw);
32804
+ } catch {
32805
+ return { ok: false, error: `createKey expects a JSON object. Got ${JSON.stringify(raw)}.` };
32806
+ }
32807
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32808
+ return { ok: false, error: `createKey expects a JSON object.` };
32809
+ }
32810
+ const o = obj;
32811
+ if (typeof o.name !== "string" || o.name.length === 0) {
32812
+ return { ok: false, error: `createKey "name" is required and must be a non-empty string.` };
32813
+ }
32814
+ const validTypes = ["permanent", "timeLimit", "disposable", "urgent"];
32815
+ if (typeof o.type !== "string" || !validTypes.includes(o.type)) {
32816
+ return { ok: false, error: `createKey "type" must be one of: ${validTypes.join(", ")}. Got ${JSON.stringify(o.type)}.` };
32817
+ }
32818
+ if (typeof o.password !== "string" || !/^\d{6,12}$/.test(o.password)) {
32819
+ return { ok: false, error: `createKey "password" must be a 6-12 digit string. Got ${JSON.stringify(o.password)}.` };
32820
+ }
32821
+ return { ok: true };
32822
+ }
32823
+ function validateKeypadDeleteKey(raw) {
32824
+ if (raw === void 0 || raw === "" || raw === "default") {
32825
+ return {
32826
+ ok: false,
32827
+ error: `deleteKey requires a JSON parameter: {"id":<passcode_id>}. Example: '{"id":12345}'.`
32828
+ };
32829
+ }
32830
+ let obj;
32831
+ try {
32832
+ obj = JSON.parse(raw);
32833
+ } catch {
32834
+ return { ok: false, error: `deleteKey expects a JSON object: {"id":<passcode_id>}. Got ${JSON.stringify(raw)}.` };
32835
+ }
32836
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32837
+ return { ok: false, error: `deleteKey expects a JSON object.` };
32838
+ }
32839
+ const o = obj;
32840
+ if (o.id === void 0 || typeof o.id !== "number" && typeof o.id !== "string") {
32841
+ return { ok: false, error: `deleteKey "id" is required (passcode ID). Got ${JSON.stringify(o.id)}.` };
32842
+ }
32843
+ return { ok: true };
32844
+ }
32292
32845
 
32293
32846
  // src/commands/batch.ts
32294
32847
  init_cjs_shim();
@@ -32518,13 +33071,7 @@ Examples:
32518
33071
  }
32519
33072
  });
32520
33073
  }
32521
- let parsedParam = parameter ?? "default";
32522
- if (parameter) {
32523
- try {
32524
- parsedParam = JSON.parse(parameter);
32525
- } catch {
32526
- }
32527
- }
33074
+ const parsedParam = parseParameterForWire(parameter);
32528
33075
  const maxConcurrentRaw = options.maxConcurrent ?? options.concurrency;
32529
33076
  const concurrency = Math.max(1, Number.parseInt(maxConcurrentRaw, 10) || DEFAULT_CONCURRENCY);
32530
33077
  const staggerMs = Math.max(0, Number.parseInt(options.stagger, 10) || 0);
@@ -33138,7 +33685,7 @@ init_catalog();
33138
33685
  init_flags();
33139
33686
  init_client();
33140
33687
  function registerExpandCommand(devices) {
33141
- devices.command("expand").description("Send a command with semantic flags instead of raw positional parameters").argument("[deviceId]", 'Target device ID from "devices list" (or use --name)').argument("[command]", "Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2), setBrightness/setColor/setColorTemperature (lighting)").option("--name <query>", "Resolve device by fuzzy name instead of deviceId", stringArg("--name")).option("--name-strategy <s>", `Name match strategy: ${ALL_STRATEGIES.join("|")} (default: require-unique)`, stringArg("--name-strategy")).option("--name-type <type>", 'Narrow --name by device type (e.g. "Curtain", "Air Conditioner")', stringArg("--name-type")).option("--name-category <cat>", "Narrow --name by category: physical|ir", enumArg("--name-category", ["physical", "ir"])).option("--name-room <room>", "Narrow --name by room name (substring match)", stringArg("--name-room")).option("--temp <celsius>", "AC setAll: temperature in Celsius (16-30)", intArg("--temp", { min: 16, max: 30 })).option("--mode <mode>", "AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary", stringArg("--mode")).option("--fan <speed>", "AC setAll: fan speed auto|low|mid|high", stringArg("--fan")).option("--power <state>", "AC setAll: on|off", stringArg("--power")).option("--position <percent>", "Curtain setPosition: 0-100 (0=open, 100=closed)", intArg("--position", { min: 0, max: 100 })).option("--direction <dir>", "Blind Tilt setPosition: up|down", stringArg("--direction")).option("--angle <percent>", "Blind Tilt setPosition: 0-100 (0=closed, 100=open)", intArg("--angle", { min: 0, max: 100 })).option("--channel <n>", "Relay Switch 2 setMode: channel 1 or 2", intArg("--channel", { min: 1, max: 2 })).option("--brightness <percent>", "setBrightness: 1-100 percent", intArg("--brightness", { min: 1, max: 100 })).option("--color <value>", "setColor: R:G:B, #RRGGBB, or named color (red, blue, etc.)", stringArg("--color")).option("--color-temp <kelvin>", "setColorTemperature: 2700-6500 Kelvin", intArg("--color-temp", { min: 2700, max: 6500 })).option("--yes", "Confirm destructive commands").addHelpText("after", `
33688
+ devices.command("expand").description("Send a command with semantic flags instead of raw positional parameters").argument("[deviceId]", 'Target device ID from "devices list" (or use --name)').argument("[command]", "Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2), setBrightness/setColor/setColorTemperature (lighting)").option("--name <query>", "Resolve device by fuzzy name instead of deviceId", stringArg("--name")).option("--name-strategy <s>", `Name match strategy: ${ALL_STRATEGIES.join("|")} (default: require-unique)`, stringArg("--name-strategy")).option("--name-type <type>", 'Narrow --name by device type (e.g. "Curtain", "Air Conditioner")', stringArg("--name-type")).option("--name-category <cat>", "Narrow --name by category: physical|ir", enumArg("--name-category", ["physical", "ir"])).option("--name-room <room>", "Narrow --name by room name (substring match)", stringArg("--name-room")).option("--temp <celsius>", "AC setAll: temperature in Celsius (16-30)", intArg("--temp", { min: 16, max: 30 })).option("--mode <mode>", "AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary", stringArg("--mode")).option("--fan <speed>", "AC setAll: fan speed auto|low|mid|high", stringArg("--fan")).option("--power <state>", "AC setAll: on|off", stringArg("--power")).option("--position <percent>", "Curtain setPosition: 0-100 (0=open, 100=closed)", intArg("--position", { min: 0, max: 100 })).option("--direction <dir>", "Blind Tilt setPosition: up|down", stringArg("--direction")).option("--angle <percent>", "Blind Tilt setPosition: 0-100 (0=closed, 100=open)", intArg("--angle", { min: 0, max: 100 })).option("--channel <n>", "Relay Switch 2 setMode: channel 1 or 2", intArg("--channel", { min: 1, max: 2 })).option("--brightness <percent>", "setBrightness: 0-100 percent (minimum depends on device)", intArg("--brightness", { min: 0, max: 100 })).option("--color <value>", "setColor: R:G:B, #RRGGBB, or named color (red, blue, etc.)", stringArg("--color")).option("--color-temp <kelvin>", "setColorTemperature: 2700-6500 Kelvin", intArg("--color-temp", { min: 2700, max: 6500 })).option("--yes", "Confirm destructive commands").addHelpText("after", `
33142
33689
  Translates semantic flags into the wire parameter format, then sends the command.
33143
33690
 
33144
33691
  Supported expansions:
@@ -33259,7 +33806,7 @@ Examples:
33259
33806
  }
33260
33807
  }
33261
33808
  if (command === "setBrightness") {
33262
- parameter = buildBrightnessSet(options);
33809
+ parameter = buildBrightnessSet(options, cached2?.type);
33263
33810
  } else if (command === "setColor") {
33264
33811
  parameter = buildColorSet(options);
33265
33812
  } else {
@@ -33943,13 +34490,7 @@ ${extra}` : extra;
33943
34490
  if (options.yes && !destructive && !isDryRun()) {
33944
34491
  console.error(`Note: --yes has no effect; "${cmd}" is not a destructive command.`);
33945
34492
  }
33946
- let parsedParam = parameter ?? "default";
33947
- if (parameter) {
33948
- try {
33949
- parsedParam = JSON.parse(parameter);
33950
- } catch {
33951
- }
33952
- }
34493
+ const parsedParam = parseParameterForWire(parameter);
33953
34494
  _cmd = cmd;
33954
34495
  _parsedParam = parsedParam;
33955
34496
  const body = await executeCommand(
@@ -49269,6 +49810,47 @@ var EventSubscriptionManager = class {
49269
49810
  }
49270
49811
  };
49271
49812
 
49813
+ // src/mcp/tool-profiles.ts
49814
+ init_cjs_shim();
49815
+ var CORE_READ = [
49816
+ "list_devices",
49817
+ "get_device_status",
49818
+ "get_device_history",
49819
+ "query_device_history",
49820
+ "list_scenes",
49821
+ "search_catalog",
49822
+ "describe_device",
49823
+ "aggregate_device_history",
49824
+ "account_overview",
49825
+ "plan_suggest"
49826
+ ];
49827
+ var CORE_ACTION = ["send_command", "run_scene", "plan_run"];
49828
+ var ADMIN = [
49829
+ "policy_validate",
49830
+ "policy_diff",
49831
+ "policy_new",
49832
+ "policy_migrate",
49833
+ "policy_add_rule",
49834
+ "audit_query",
49835
+ "audit_stats",
49836
+ "rule_notifications",
49837
+ "rules_suggest",
49838
+ "rules_explain",
49839
+ "rules_simulate"
49840
+ ];
49841
+ var TOOL_PROFILES = {
49842
+ readonly: new Set(CORE_READ),
49843
+ default: /* @__PURE__ */ new Set([...CORE_READ, ...CORE_ACTION]),
49844
+ all: /* @__PURE__ */ new Set([...CORE_READ, ...CORE_ACTION, ...ADMIN])
49845
+ };
49846
+ var VALID_PROFILES = Object.keys(TOOL_PROFILES);
49847
+ function resolveToolProfile(name) {
49848
+ if (!name || name === "default") return "default";
49849
+ if (name === "readonly" || name === "all") return name;
49850
+ const valid = VALID_PROFILES.join(", ");
49851
+ throw new Error(`Unknown tool profile "${name}". Valid profiles: ${valid}`);
49852
+ }
49853
+
49272
49854
  // src/devices/history-query.ts
49273
49855
  init_cjs_shim();
49274
49856
  import fs10 from "node:fs";
@@ -51139,6 +51721,8 @@ function buildRiskProfile(typeName, command, commandType, isDestructive) {
51139
51721
  }
51140
51722
  function createSwitchBotMcpServer(options) {
51141
51723
  const eventManager = options?.eventManager;
51724
+ const allowedTools = TOOL_PROFILES[options?.toolProfile ?? "default"];
51725
+ const profileName = options?.toolProfile ?? "default";
51142
51726
  const server = new McpServer(
51143
51727
  {
51144
51728
  name: "switchbot",
@@ -51162,1075 +51746,1093 @@ Recommended bootstrap sequence:
51162
51746
  2. search_catalog or describe_device \u2192 confirm supported commands offline/online
51163
51747
  3. send_command (with confirm:true for destructive commands)
51164
51748
 
51165
- API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
51749
+ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI
51750
+
51751
+ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName !== "all" ? " Use --tools all to access admin tools (policy, audit, rules)." : ""}`
51166
51752
  }
51167
51753
  );
51168
- server.registerTool(
51169
- "list_devices",
51170
- {
51171
- title: "List all devices on the account",
51172
- description: "Fetch the complete inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local metadata cache and groups devices by type. Use this as the bootstrap call to discover available deviceIds. Devices without enableCloudService cannot receive commands via API. IR remotes depend on a Hub for connectivity.",
51173
- _meta: { agentSafetyTier: "read" },
51174
- inputSchema: external_exports.object({}).strict(),
51175
- outputSchema: {
51176
- deviceList: external_exports.array(external_exports.object({
51177
- deviceId: external_exports.string(),
51178
- deviceName: external_exports.string(),
51179
- deviceType: external_exports.string().optional(),
51180
- enableCloudService: external_exports.boolean(),
51181
- hubDeviceId: external_exports.string(),
51182
- roomID: external_exports.string().optional(),
51183
- roomName: external_exports.string().nullable().optional(),
51184
- familyName: external_exports.string().optional(),
51185
- controlType: external_exports.string().optional()
51186
- }).passthrough()).describe("Physical SwitchBot devices"),
51187
- infraredRemoteList: external_exports.array(external_exports.object({
51188
- deviceId: external_exports.string(),
51189
- deviceName: external_exports.string(),
51190
- remoteType: external_exports.string(),
51191
- hubDeviceId: external_exports.string(),
51192
- controlType: external_exports.string().optional()
51193
- }).passthrough()).describe("IR remote devices")
51194
- }
51195
- },
51196
- async () => {
51197
- const body = await fetchDeviceList();
51198
- return {
51199
- content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
51200
- structuredContent: {
51201
- deviceList: body.deviceList.map(toMcpDeviceListShape),
51202
- infraredRemoteList: body.infraredRemoteList.map(toMcpIrDeviceShape)
51754
+ const skip = (name) => !allowedTools.has(name);
51755
+ if (!skip("list_devices"))
51756
+ server.registerTool(
51757
+ "list_devices",
51758
+ {
51759
+ title: "List all devices on the account",
51760
+ description: "Fetch the complete inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local metadata cache and groups devices by type. Use this as the bootstrap call to discover available deviceIds. Devices without enableCloudService cannot receive commands via API. IR remotes depend on a Hub for connectivity.",
51761
+ _meta: { agentSafetyTier: "read" },
51762
+ inputSchema: external_exports.object({}).strict(),
51763
+ outputSchema: {
51764
+ deviceList: external_exports.array(external_exports.object({
51765
+ deviceId: external_exports.string(),
51766
+ deviceName: external_exports.string(),
51767
+ deviceType: external_exports.string().optional(),
51768
+ enableCloudService: external_exports.boolean(),
51769
+ hubDeviceId: external_exports.string(),
51770
+ roomID: external_exports.string().optional(),
51771
+ roomName: external_exports.string().nullable().optional(),
51772
+ familyName: external_exports.string().optional(),
51773
+ controlType: external_exports.string().optional()
51774
+ }).passthrough()).describe("Physical SwitchBot devices"),
51775
+ infraredRemoteList: external_exports.array(external_exports.object({
51776
+ deviceId: external_exports.string(),
51777
+ deviceName: external_exports.string(),
51778
+ remoteType: external_exports.string(),
51779
+ hubDeviceId: external_exports.string(),
51780
+ controlType: external_exports.string().optional()
51781
+ }).passthrough()).describe("IR remote devices")
51203
51782
  }
51204
- };
51205
- }
51206
- );
51207
- server.registerTool(
51208
- "get_device_status",
51209
- {
51210
- title: "Get live status for a device",
51211
- description: "Query the real-time status payload for a physical device. IR remotes have no status channel and will error.",
51212
- _meta: { agentSafetyTier: "read" },
51213
- inputSchema: external_exports.object({
51214
- deviceId: external_exports.string().describe("Device ID from list_devices")
51215
- }).strict(),
51216
- outputSchema: {
51217
- status: external_exports.object({
51218
- deviceId: external_exports.string().optional(),
51219
- deviceType: external_exports.string().optional(),
51220
- hubDeviceId: external_exports.string().optional(),
51221
- connectionStatus: external_exports.string().optional()
51222
- }).passthrough().describe("Live device status (deviceId + deviceType + device-specific fields)")
51223
- }
51224
- },
51225
- async ({ deviceId }) => {
51226
- const body = await fetchDeviceStatus(deviceId);
51227
- return {
51228
- content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
51229
- structuredContent: { status: body }
51230
- };
51231
- }
51232
- );
51233
- server.registerTool(
51234
- "get_device_history",
51235
- {
51236
- title: "Get locally-persisted device state history",
51237
- description: "Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). No API call \u2014 zero quota cost. Use when you need recent historical readings or want to avoid a live API call. Omit deviceId to list all devices with stored history.",
51238
- _meta: { agentSafetyTier: "read" },
51239
- inputSchema: external_exports.object({
51240
- deviceId: external_exports.string().optional().describe("Device MAC address (deviceId). Omit to list all devices with history."),
51241
- limit: external_exports.number().int().min(1).max(100).optional().describe("Max history entries to return (default 20, max 100)")
51242
- }).strict(),
51243
- outputSchema: {
51244
- deviceId: external_exports.string().optional(),
51245
- latest: external_exports.unknown().optional(),
51246
- history: external_exports.array(external_exports.unknown()).optional(),
51247
- devices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), latest: external_exports.unknown() })).optional()
51248
- }
51249
- },
51250
- async ({ deviceId, limit }) => {
51251
- if (deviceId) {
51252
- const latest = deviceHistoryStore.getLatest(deviceId);
51253
- const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
51254
- const result2 = { deviceId, latest, history };
51783
+ },
51784
+ async () => {
51785
+ const body = await fetchDeviceList();
51255
51786
  return {
51256
- content: [{ type: "text", text: JSON.stringify(result2, null, 2) }],
51257
- structuredContent: result2
51787
+ content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
51788
+ structuredContent: {
51789
+ deviceList: body.deviceList.map(toMcpDeviceListShape),
51790
+ infraredRemoteList: body.infraredRemoteList.map(toMcpIrDeviceShape)
51791
+ }
51258
51792
  };
51259
51793
  }
51260
- const ids = deviceHistoryStore.listDevices();
51261
- const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) }));
51262
- const result = { devices };
51263
- return {
51264
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
51265
- structuredContent: result
51266
- };
51267
- }
51268
- );
51269
- server.registerTool(
51270
- "query_device_history",
51271
- {
51272
- title: "Query time-ranged device history",
51273
- description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) filtered by a relative duration (since) or absolute ISO-8601 range (from/to). No API call \u2014 zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
51274
- _meta: { agentSafetyTier: "read" },
51275
- inputSchema: external_exports.object({
51276
- deviceId: external_exports.string().describe("Device ID to query"),
51277
- since: external_exports.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
51278
- from: external_exports.string().optional().describe("Range start (ISO-8601)."),
51279
- to: external_exports.string().optional().describe("Range end (ISO-8601)."),
51280
- fields: external_exports.array(external_exports.string()).optional().describe("Project these payload fields; omit for the full payload."),
51281
- limit: external_exports.number().int().min(1).max(1e4).optional().describe("Max records to return (default 1000).")
51282
- }).strict(),
51283
- outputSchema: {
51284
- deviceId: external_exports.string(),
51285
- count: external_exports.number().int(),
51286
- records: external_exports.array(external_exports.object({
51287
- t: external_exports.string(),
51288
- topic: external_exports.string(),
51289
- deviceType: external_exports.string().optional(),
51290
- payload: external_exports.unknown()
51291
- }))
51292
- }
51293
- },
51294
- async ({ deviceId, since, from, to, fields, limit }) => {
51295
- if (since && (from || to)) {
51296
- return mcpError("usage", 2, "--since is mutually exclusive with --from/--to.");
51794
+ );
51795
+ if (!skip("get_device_status"))
51796
+ server.registerTool(
51797
+ "get_device_status",
51798
+ {
51799
+ title: "Get live status for a device",
51800
+ description: "Query the real-time status payload for a physical device. IR remotes have no status channel and will error.",
51801
+ _meta: { agentSafetyTier: "read" },
51802
+ inputSchema: external_exports.object({
51803
+ deviceId: external_exports.string().describe("Device ID from list_devices")
51804
+ }).strict(),
51805
+ outputSchema: {
51806
+ status: external_exports.object({
51807
+ deviceId: external_exports.string().optional(),
51808
+ deviceType: external_exports.string().optional(),
51809
+ hubDeviceId: external_exports.string().optional(),
51810
+ connectionStatus: external_exports.string().optional()
51811
+ }).passthrough().describe("Live device status (deviceId + deviceType + device-specific fields)")
51812
+ }
51813
+ },
51814
+ async ({ deviceId }) => {
51815
+ const body = await fetchDeviceStatus(deviceId);
51816
+ return {
51817
+ content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
51818
+ structuredContent: { status: body }
51819
+ };
51297
51820
  }
51298
- try {
51299
- const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
51300
- const result = { deviceId, count: records.length, records };
51821
+ );
51822
+ if (!skip("get_device_history"))
51823
+ server.registerTool(
51824
+ "get_device_history",
51825
+ {
51826
+ title: "Get locally-persisted device state history",
51827
+ description: "Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). No API call \u2014 zero quota cost. Use when you need recent historical readings or want to avoid a live API call. Omit deviceId to list all devices with stored history.",
51828
+ _meta: { agentSafetyTier: "read" },
51829
+ inputSchema: external_exports.object({
51830
+ deviceId: external_exports.string().optional().describe("Device MAC address (deviceId). Omit to list all devices with history."),
51831
+ limit: external_exports.number().int().min(1).max(100).optional().describe("Max history entries to return (default 20, max 100)")
51832
+ }).strict(),
51833
+ outputSchema: {
51834
+ deviceId: external_exports.string().optional(),
51835
+ latest: external_exports.unknown().optional(),
51836
+ history: external_exports.array(external_exports.unknown()).optional(),
51837
+ devices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), latest: external_exports.unknown() })).optional()
51838
+ }
51839
+ },
51840
+ async ({ deviceId, limit }) => {
51841
+ if (deviceId) {
51842
+ const latest = deviceHistoryStore.getLatest(deviceId);
51843
+ const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
51844
+ const result2 = { deviceId, latest, history };
51845
+ return {
51846
+ content: [{ type: "text", text: JSON.stringify(result2, null, 2) }],
51847
+ structuredContent: result2
51848
+ };
51849
+ }
51850
+ const ids = deviceHistoryStore.listDevices();
51851
+ const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) }));
51852
+ const result = { devices };
51301
51853
  return {
51302
51854
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
51303
51855
  structuredContent: result
51304
51856
  };
51305
- } catch (err) {
51306
- const msg = err instanceof Error ? err.message : "history query failed";
51307
- return mcpError("usage", 2, msg);
51308
51857
  }
51309
- }
51310
- );
51311
- server.registerTool(
51312
- "send_command",
51313
- {
51314
- title: "Send a control command to a device",
51315
- description: "Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands require confirm:true and are still blocked in the default safety profile; use the reviewed plan workflow unless an explicit dev profile allows direct execution. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.",
51316
- _meta: { agentSafetyTier: "action" },
51317
- inputSchema: external_exports.object({
51318
- deviceId: external_exports.string().describe("Device ID from list_devices"),
51319
- command: external_exports.string().describe("Command name, case-sensitive (e.g. turnOn, setColor, unlock)"),
51320
- parameter: external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.record(external_exports.string(), external_exports.unknown()), external_exports.array(external_exports.unknown())]).optional().describe("Command parameter. Omit for no-arg commands."),
51321
- commandType: external_exports.enum(["command", "customize"]).optional().default("command").describe('"command" for built-in commands; "customize" for user-defined IR buttons'),
51322
- confirm: external_exports.boolean().optional().default(false).describe("Required true for destructive commands (unlock, garage open, createKey, ...)"),
51323
- idempotencyKey: external_exports.string().optional().describe(
51324
- "Deduplication key \u2014 repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error."
51325
- ),
51326
- dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
51327
- }).strict(),
51328
- outputSchema: {
51329
- ok: external_exports.literal(true),
51330
- command: external_exports.string().optional(),
51331
- deviceId: external_exports.string().optional(),
51332
- result: external_exports.unknown().optional().describe("API response body from SwitchBot (absent on dryRun)"),
51333
- riskProfile: external_exports.object({
51334
- riskLevel: external_exports.enum(["high", "medium", "low"]),
51335
- requiresConfirmation: external_exports.boolean(),
51336
- supportsDryRun: external_exports.literal(true),
51337
- idempotencyHint: external_exports.enum(["safe", "non-idempotent"]),
51338
- recommendedMode: external_exports.enum(["review-before-execute", "plan", "direct"])
51339
- }).optional().describe(
51340
- 'Device+command-specific risk metadata. riskLevel:"high" means confirm:true was required. Always present on dryRun responses so agents can preview risk before committing.'
51341
- ),
51342
- verification: external_exports.object({
51343
- verifiable: external_exports.boolean(),
51344
- reason: external_exports.string(),
51345
- suggestedFollowup: external_exports.string()
51346
- }).optional().describe(
51347
- 'Present when the target is an IR device. IR is unidirectional \u2014 agents should treat the success as "signal sent" not "state changed".'
51348
- ),
51349
- dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
51350
- wouldSend: external_exports.object({
51858
+ );
51859
+ if (!skip("query_device_history"))
51860
+ server.registerTool(
51861
+ "query_device_history",
51862
+ {
51863
+ title: "Query time-ranged device history",
51864
+ description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) filtered by a relative duration (since) or absolute ISO-8601 range (from/to). No API call \u2014 zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
51865
+ _meta: { agentSafetyTier: "read" },
51866
+ inputSchema: external_exports.object({
51867
+ deviceId: external_exports.string().describe("Device ID to query"),
51868
+ since: external_exports.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
51869
+ from: external_exports.string().optional().describe("Range start (ISO-8601)."),
51870
+ to: external_exports.string().optional().describe("Range end (ISO-8601)."),
51871
+ fields: external_exports.array(external_exports.string()).optional().describe("Project these payload fields; omit for the full payload."),
51872
+ limit: external_exports.number().int().min(1).max(1e4).optional().describe("Max records to return (default 1000).")
51873
+ }).strict(),
51874
+ outputSchema: {
51351
51875
  deviceId: external_exports.string(),
51352
- command: external_exports.string(),
51353
- parameter: external_exports.unknown(),
51354
- commandType: external_exports.string()
51355
- }).optional().describe("The request shape that would have been POSTed (present when dryRun:true)")
51876
+ count: external_exports.number().int(),
51877
+ records: external_exports.array(external_exports.object({
51878
+ t: external_exports.string(),
51879
+ topic: external_exports.string(),
51880
+ deviceType: external_exports.string().optional(),
51881
+ payload: external_exports.unknown()
51882
+ }))
51883
+ }
51884
+ },
51885
+ async ({ deviceId, since, from, to, fields, limit }) => {
51886
+ if (since && (from || to)) {
51887
+ return mcpError("usage", 2, "--since is mutually exclusive with --from/--to.");
51888
+ }
51889
+ try {
51890
+ const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
51891
+ const result = { deviceId, count: records.length, records };
51892
+ return {
51893
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
51894
+ structuredContent: result
51895
+ };
51896
+ } catch (err) {
51897
+ const msg = err instanceof Error ? err.message : "history query failed";
51898
+ return mcpError("usage", 2, msg);
51899
+ }
51356
51900
  }
51357
- },
51358
- async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
51359
- const effectiveType = commandType ?? "command";
51360
- let effectiveCommand = command;
51361
- let effectiveParameter = parameter;
51362
- const stringifiedParam = parameter === void 0 ? void 0 : typeof parameter === "string" ? parameter : JSON.stringify(parameter);
51363
- if (dryRun) {
51364
- const cached2 = getCachedDevice(deviceId);
51365
- if (!cached2) {
51366
- return mcpError("usage", 2, `Device "${deviceId}" not found in local cache.`, {
51367
- subKind: "device-not-found",
51368
- hint: "Run 'list_devices' first to warm the cache, then retry with dryRun:true.",
51369
- context: { deviceId }
51370
- });
51901
+ );
51902
+ if (!skip("send_command"))
51903
+ server.registerTool(
51904
+ "send_command",
51905
+ {
51906
+ title: "Send a control command to a device",
51907
+ description: "Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands require confirm:true and are still blocked in the default safety profile; use the reviewed plan workflow unless an explicit dev profile allows direct execution. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.",
51908
+ _meta: { agentSafetyTier: "action" },
51909
+ inputSchema: external_exports.object({
51910
+ deviceId: external_exports.string().describe("Device ID from list_devices"),
51911
+ command: external_exports.string().describe("Command name, case-sensitive (e.g. turnOn, setColor, unlock)"),
51912
+ parameter: external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.record(external_exports.string(), external_exports.unknown()), external_exports.array(external_exports.unknown())]).optional().describe("Command parameter. Omit for no-arg commands."),
51913
+ commandType: external_exports.enum(["command", "customize"]).optional().default("command").describe('"command" for built-in commands; "customize" for user-defined IR buttons'),
51914
+ confirm: external_exports.boolean().optional().default(false).describe("Required true for destructive commands (unlock, garage open, createKey, ...)"),
51915
+ idempotencyKey: external_exports.string().optional().describe(
51916
+ "Deduplication key \u2014 repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error."
51917
+ ),
51918
+ dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
51919
+ }).strict(),
51920
+ outputSchema: {
51921
+ ok: external_exports.literal(true),
51922
+ command: external_exports.string().optional(),
51923
+ deviceId: external_exports.string().optional(),
51924
+ result: external_exports.unknown().optional().describe("API response body from SwitchBot (absent on dryRun)"),
51925
+ riskProfile: external_exports.object({
51926
+ riskLevel: external_exports.enum(["high", "medium", "low"]),
51927
+ requiresConfirmation: external_exports.boolean(),
51928
+ supportsDryRun: external_exports.literal(true),
51929
+ idempotencyHint: external_exports.enum(["safe", "non-idempotent"]),
51930
+ recommendedMode: external_exports.enum(["review-before-execute", "plan", "direct"])
51931
+ }).optional().describe(
51932
+ 'Device+command-specific risk metadata. riskLevel:"high" means confirm:true was required. Always present on dryRun responses so agents can preview risk before committing.'
51933
+ ),
51934
+ verification: external_exports.object({
51935
+ verifiable: external_exports.boolean(),
51936
+ reason: external_exports.string(),
51937
+ suggestedFollowup: external_exports.string()
51938
+ }).optional().describe(
51939
+ 'Present when the target is an IR device. IR is unidirectional \u2014 agents should treat the success as "signal sent" not "state changed".'
51940
+ ),
51941
+ dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
51942
+ wouldSend: external_exports.object({
51943
+ deviceId: external_exports.string(),
51944
+ command: external_exports.string(),
51945
+ parameter: external_exports.unknown(),
51946
+ commandType: external_exports.string()
51947
+ }).optional().describe("The request shape that would have been POSTed (present when dryRun:true)")
51371
51948
  }
51372
- const dryValidation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
51373
- if (!dryValidation.ok) {
51949
+ },
51950
+ async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
51951
+ const effectiveType = commandType ?? "command";
51952
+ let effectiveCommand = command;
51953
+ let effectiveParameter = parameter;
51954
+ const stringifiedParam = parameter === void 0 ? void 0 : typeof parameter === "string" ? parameter : JSON.stringify(parameter);
51955
+ if (dryRun) {
51956
+ const cached2 = getCachedDevice(deviceId);
51957
+ if (!cached2) {
51958
+ return mcpError("usage", 2, `Device "${deviceId}" not found in local cache.`, {
51959
+ subKind: "device-not-found",
51960
+ hint: "Run 'list_devices' first to warm the cache, then retry with dryRun:true.",
51961
+ context: { deviceId }
51962
+ });
51963
+ }
51964
+ const dryValidation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
51965
+ if (!dryValidation.ok) {
51966
+ return mcpError(
51967
+ "usage",
51968
+ 2,
51969
+ dryValidation.error.message,
51970
+ {
51971
+ hint: dryValidation.error.hint,
51972
+ context: {
51973
+ validationKind: dryValidation.error.kind,
51974
+ deviceType: cached2.type,
51975
+ command: effectiveCommand
51976
+ }
51977
+ }
51978
+ );
51979
+ }
51980
+ if (dryValidation.normalized) {
51981
+ effectiveCommand = dryValidation.normalized;
51982
+ }
51983
+ if (effectiveType !== "customize") {
51984
+ const pv = validateParameter(cached2.type, effectiveCommand, stringifiedParam);
51985
+ if (!pv.ok) {
51986
+ return mcpError("usage", 2, pv.error, {
51987
+ hint: "Dry-run rejected the parameter client-side; the API would reject it too.",
51988
+ context: { deviceType: cached2.type, command: effectiveCommand, parameter: stringifiedParam }
51989
+ });
51990
+ }
51991
+ if (pv.normalized !== void 0) {
51992
+ effectiveParameter = parseParameterForWire(pv.normalized);
51993
+ }
51994
+ }
51995
+ const wouldSend = {
51996
+ deviceId,
51997
+ command: effectiveCommand,
51998
+ parameter: effectiveParameter ?? "default",
51999
+ commandType: effectiveType
52000
+ };
52001
+ const dryIsDestructive = isDestructiveCommand(cached2.type, effectiveCommand, effectiveType);
52002
+ const dryRiskProfile = buildRiskProfile(cached2.type, effectiveCommand, effectiveType, dryIsDestructive);
52003
+ const structured2 = { ok: true, dryRun: true, riskProfile: dryRiskProfile, wouldSend };
52004
+ return {
52005
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52006
+ structuredContent: structured2
52007
+ };
52008
+ }
52009
+ let typeName = getCachedDevice(deviceId)?.type;
52010
+ if (!typeName) {
52011
+ const body = await fetchDeviceList();
52012
+ const physical = body.deviceList.find((d) => d.deviceId === deviceId);
52013
+ const ir = body.infraredRemoteList.find((d) => d.deviceId === deviceId);
52014
+ if (!physical && !ir) {
52015
+ return mcpError("runtime", 152, `Device not found: ${deviceId}`, {
52016
+ hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive)."
52017
+ });
52018
+ }
52019
+ typeName = physical ? physical.deviceType : ir.remoteType;
52020
+ }
52021
+ const destructive = isDestructiveCommand(typeName, effectiveCommand, effectiveType);
52022
+ if (destructive && !allowsDirectDestructiveExecution()) {
52023
+ const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
51374
52024
  return mcpError(
51375
- "usage",
51376
- 2,
51377
- dryValidation.error.message,
52025
+ "guard",
52026
+ 3,
52027
+ `Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`,
51378
52028
  {
51379
- hint: dryValidation.error.hint,
52029
+ hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
51380
52030
  context: {
51381
- validationKind: dryValidation.error.kind,
51382
- deviceType: cached2.type,
51383
- command: effectiveCommand
52031
+ command: effectiveCommand,
52032
+ deviceType: typeName,
52033
+ directExecutionAllowed: false,
52034
+ requiredWorkflow: "plan-approval",
52035
+ ...reason ? { safetyReason: reason, destructiveReason: reason } : {}
51384
52036
  }
51385
52037
  }
51386
52038
  );
51387
52039
  }
51388
- if (dryValidation.normalized) {
51389
- effectiveCommand = dryValidation.normalized;
52040
+ if (destructive && !confirm) {
52041
+ const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
52042
+ const entry = typeName ? findCatalogEntry(typeName) : null;
52043
+ const spec = entry && !Array.isArray(entry) ? entry.commands.find((c) => c.command === effectiveCommand) : void 0;
52044
+ const hint = reason ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}` : "Re-issue the call with confirm:true to proceed.";
52045
+ return mcpError(
52046
+ "guard",
52047
+ 3,
52048
+ `Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`,
52049
+ {
52050
+ hint,
52051
+ context: {
52052
+ command: effectiveCommand,
52053
+ deviceType: typeName,
52054
+ description: spec?.description ?? null,
52055
+ ...reason ? { safetyReason: reason, destructiveReason: reason } : {}
52056
+ }
52057
+ }
52058
+ );
52059
+ }
52060
+ const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
52061
+ if (!validation.ok) {
52062
+ return mcpError(
52063
+ "usage",
52064
+ 2,
52065
+ validation.error.message,
52066
+ {
52067
+ hint: validation.error.hint,
52068
+ context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand }
52069
+ }
52070
+ );
52071
+ }
52072
+ if (validation.normalized) {
52073
+ effectiveCommand = validation.normalized;
51390
52074
  }
51391
52075
  if (effectiveType !== "customize") {
51392
- const pv = validateParameter(cached2.type, effectiveCommand, stringifiedParam);
52076
+ const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
51393
52077
  if (!pv.ok) {
51394
52078
  return mcpError("usage", 2, pv.error, {
51395
- hint: "Dry-run rejected the parameter client-side; the API would reject it too.",
51396
- context: { deviceType: cached2.type, command: effectiveCommand, parameter: stringifiedParam }
52079
+ context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: "param-out-of-range" }
51397
52080
  });
51398
52081
  }
51399
52082
  if (pv.normalized !== void 0) {
51400
- effectiveParameter = pv.normalized;
52083
+ effectiveParameter = parseParameterForWire(pv.normalized);
51401
52084
  }
51402
52085
  }
51403
- const wouldSend = {
51404
- deviceId,
51405
- command: effectiveCommand,
51406
- parameter: effectiveParameter ?? "default",
51407
- commandType: effectiveType
51408
- };
51409
- const dryIsDestructive = isDestructiveCommand(cached2.type, effectiveCommand, effectiveType);
51410
- const dryRiskProfile = buildRiskProfile(cached2.type, effectiveCommand, effectiveType, dryIsDestructive);
51411
- const structured2 = { ok: true, dryRun: true, riskProfile: dryRiskProfile, wouldSend };
52086
+ let result;
52087
+ try {
52088
+ result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, void 0, {
52089
+ idempotencyKey
52090
+ });
52091
+ } catch (err) {
52092
+ if (err instanceof Error && err.name === "IdempotencyConflictError") {
52093
+ return mcpError("guard", 2, err.message, {
52094
+ hint: "Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).",
52095
+ context: {
52096
+ existingShape: err.existingShape,
52097
+ newShape: err.newShape
52098
+ }
52099
+ });
52100
+ }
52101
+ return apiErrorToMcpError(err);
52102
+ }
52103
+ const isIr = getCachedDevice(deviceId)?.category === "ir";
52104
+ const liveIsDestructive = destructive;
52105
+ const riskProfile = buildRiskProfile(typeName, effectiveCommand, effectiveType, liveIsDestructive);
52106
+ const structured = { ok: true, command: effectiveCommand, deviceId, result, riskProfile };
52107
+ if (isIr) {
52108
+ structured.verification = {
52109
+ verifiable: false,
52110
+ reason: "IR transmission is unidirectional; no receipt acknowledgment is possible.",
52111
+ suggestedFollowup: "Confirm visible change manually or via a paired state sensor."
52112
+ };
52113
+ }
51412
52114
  return {
51413
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
51414
- structuredContent: structured2
52115
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52116
+ structuredContent: structured
51415
52117
  };
51416
52118
  }
51417
- let typeName = getCachedDevice(deviceId)?.type;
51418
- if (!typeName) {
51419
- const body = await fetchDeviceList();
51420
- const physical = body.deviceList.find((d) => d.deviceId === deviceId);
51421
- const ir = body.infraredRemoteList.find((d) => d.deviceId === deviceId);
51422
- if (!physical && !ir) {
51423
- return mcpError("runtime", 152, `Device not found: ${deviceId}`, {
51424
- hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive)."
51425
- });
52119
+ );
52120
+ if (!skip("run_scene"))
52121
+ server.registerTool(
52122
+ "run_scene",
52123
+ {
52124
+ title: "Execute a manual scene",
52125
+ description: "Execute a manual SwitchBot scene by its sceneId (from list_scenes).",
52126
+ _meta: { agentSafetyTier: "action" },
52127
+ inputSchema: external_exports.object({
52128
+ sceneId: external_exports.string().describe("Scene ID from list_scenes"),
52129
+ dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
52130
+ }).strict(),
52131
+ outputSchema: {
52132
+ ok: external_exports.literal(true),
52133
+ sceneId: external_exports.string().optional(),
52134
+ dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
52135
+ wouldSend: external_exports.object({
52136
+ sceneId: external_exports.string()
52137
+ }).optional().describe("The request shape that would have been POSTed (present when dryRun:true)")
51426
52138
  }
51427
- typeName = physical ? physical.deviceType : ir.remoteType;
51428
- }
51429
- const destructive = isDestructiveCommand(typeName, effectiveCommand, effectiveType);
51430
- if (destructive && !allowsDirectDestructiveExecution()) {
51431
- const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
51432
- return mcpError(
51433
- "guard",
51434
- 3,
51435
- `Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`,
51436
- {
51437
- hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
51438
- context: {
51439
- command: effectiveCommand,
51440
- deviceType: typeName,
51441
- directExecutionAllowed: false,
51442
- requiredWorkflow: "plan-approval",
51443
- ...reason ? { safetyReason: reason, destructiveReason: reason } : {}
51444
- }
51445
- }
51446
- );
51447
- }
51448
- if (destructive && !confirm) {
51449
- const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
51450
- const entry = typeName ? findCatalogEntry(typeName) : null;
51451
- const spec = entry && !Array.isArray(entry) ? entry.commands.find((c) => c.command === effectiveCommand) : void 0;
51452
- const hint = reason ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}` : "Re-issue the call with confirm:true to proceed.";
51453
- return mcpError(
51454
- "guard",
51455
- 3,
51456
- `Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`,
51457
- {
51458
- hint,
51459
- context: {
51460
- command: effectiveCommand,
51461
- deviceType: typeName,
51462
- description: spec?.description ?? null,
51463
- ...reason ? { safetyReason: reason, destructiveReason: reason } : {}
51464
- }
52139
+ },
52140
+ async ({ sceneId, dryRun }) => {
52141
+ if (dryRun) {
52142
+ let scenes = [];
52143
+ try {
52144
+ scenes = await fetchScenes();
52145
+ } catch {
51465
52146
  }
51466
- );
51467
- }
51468
- const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
51469
- if (!validation.ok) {
51470
- return mcpError(
51471
- "usage",
51472
- 2,
51473
- validation.error.message,
51474
- {
51475
- hint: validation.error.hint,
51476
- context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand }
52147
+ const found = scenes.find((s2) => s2.sceneId === sceneId);
52148
+ if (scenes.length > 0 && !found) {
52149
+ return mcpError("usage", 2, `Scene not found: ${sceneId}`, {
52150
+ subKind: "scene-not-found",
52151
+ hint: "Check the sceneId with 'list_scenes' (IDs are case-sensitive).",
52152
+ context: { sceneId, candidates: scenes.map((s2) => ({ sceneId: s2.sceneId, sceneName: s2.sceneName })).slice(0, 5) }
52153
+ });
51477
52154
  }
51478
- );
51479
- }
51480
- if (validation.normalized) {
51481
- effectiveCommand = validation.normalized;
51482
- }
51483
- if (effectiveType !== "customize") {
51484
- const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
51485
- if (!pv.ok) {
51486
- return mcpError("usage", 2, pv.error, {
51487
- context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: "param-out-of-range" }
51488
- });
52155
+ const wouldSend = { sceneId, sceneName: found?.sceneName ?? null };
52156
+ const structured2 = { ok: true, dryRun: true, wouldSend };
52157
+ return {
52158
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52159
+ structuredContent: structured2
52160
+ };
51489
52161
  }
51490
- if (pv.normalized !== void 0) {
51491
- effectiveParameter = pv.normalized;
52162
+ try {
52163
+ await executeScene(sceneId);
52164
+ } catch (err) {
52165
+ return apiErrorToMcpError(err);
51492
52166
  }
52167
+ const structured = { ok: true, sceneId };
52168
+ return {
52169
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52170
+ structuredContent: structured
52171
+ };
51493
52172
  }
51494
- let result;
51495
- try {
51496
- result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, void 0, {
51497
- idempotencyKey
51498
- });
51499
- } catch (err) {
51500
- if (err instanceof Error && err.name === "IdempotencyConflictError") {
51501
- return mcpError("guard", 2, err.message, {
51502
- hint: "Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).",
51503
- context: {
51504
- existingShape: err.existingShape,
51505
- newShape: err.newShape
51506
- }
51507
- });
52173
+ );
52174
+ if (!skip("list_scenes"))
52175
+ server.registerTool(
52176
+ "list_scenes",
52177
+ {
52178
+ title: "List all manual scenes",
52179
+ description: "Fetch all manual scenes configured in the SwitchBot app.",
52180
+ _meta: { agentSafetyTier: "read" },
52181
+ inputSchema: external_exports.object({}).strict(),
52182
+ outputSchema: {
52183
+ scenes: external_exports.array(external_exports.object({ sceneId: external_exports.string(), sceneName: external_exports.string() }))
51508
52184
  }
51509
- return apiErrorToMcpError(err);
51510
- }
51511
- const isIr = getCachedDevice(deviceId)?.category === "ir";
51512
- const liveIsDestructive = destructive;
51513
- const riskProfile = buildRiskProfile(typeName, effectiveCommand, effectiveType, liveIsDestructive);
51514
- const structured = { ok: true, command: effectiveCommand, deviceId, result, riskProfile };
51515
- if (isIr) {
51516
- structured.verification = {
51517
- verifiable: false,
51518
- reason: "IR transmission is unidirectional; no receipt acknowledgment is possible.",
51519
- suggestedFollowup: "Confirm visible change manually or via a paired state sensor."
52185
+ },
52186
+ async () => {
52187
+ const scenes = await fetchScenes();
52188
+ return {
52189
+ content: [{ type: "text", text: JSON.stringify(scenes, null, 2) }],
52190
+ structuredContent: { scenes }
51520
52191
  };
51521
52192
  }
51522
- return {
51523
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51524
- structuredContent: structured
51525
- };
51526
- }
51527
- );
51528
- server.registerTool(
51529
- "run_scene",
51530
- {
51531
- title: "Execute a manual scene",
51532
- description: "Execute a manual SwitchBot scene by its sceneId (from list_scenes).",
51533
- _meta: { agentSafetyTier: "action" },
51534
- inputSchema: external_exports.object({
51535
- sceneId: external_exports.string().describe("Scene ID from list_scenes"),
51536
- dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
51537
- }).strict(),
51538
- outputSchema: {
51539
- ok: external_exports.literal(true),
51540
- sceneId: external_exports.string().optional(),
51541
- dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
51542
- wouldSend: external_exports.object({
51543
- sceneId: external_exports.string()
51544
- }).optional().describe("The request shape that would have been POSTed (present when dryRun:true)")
51545
- }
51546
- },
51547
- async ({ sceneId, dryRun }) => {
51548
- if (dryRun) {
51549
- let scenes = [];
51550
- try {
51551
- scenes = await fetchScenes();
51552
- } catch {
52193
+ );
52194
+ if (!skip("search_catalog"))
52195
+ server.registerTool(
52196
+ "search_catalog",
52197
+ {
52198
+ title: "Search the offline device catalog",
52199
+ description: "Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.",
52200
+ _meta: { agentSafetyTier: "read" },
52201
+ inputSchema: external_exports.object({
52202
+ query: external_exports.string().describe("Search query (matches type and aliases, case-insensitive). Must be non-empty; use list_catalog_types to enumerate instead."),
52203
+ limit: external_exports.number().int().min(1).max(100).optional().default(20).describe("Max entries returned (default 20)")
52204
+ }).strict(),
52205
+ outputSchema: {
52206
+ results: external_exports.array(external_exports.object({
52207
+ type: external_exports.string(),
52208
+ category: external_exports.enum(["physical", "ir"]),
52209
+ commands: external_exports.array(external_exports.object({
52210
+ command: external_exports.string(),
52211
+ parameter: external_exports.string(),
52212
+ description: external_exports.string(),
52213
+ commandType: external_exports.enum(["command", "customize"]).optional(),
52214
+ idempotent: external_exports.boolean().optional(),
52215
+ safetyTier: external_exports.enum(["read", "mutation", "ir-fire-forget", "destructive", "maintenance"]).optional(),
52216
+ safetyReason: external_exports.string().optional()
52217
+ }).passthrough()),
52218
+ aliases: external_exports.array(external_exports.string()).optional(),
52219
+ statusFields: external_exports.array(external_exports.string()).optional(),
52220
+ role: external_exports.string().optional(),
52221
+ readOnly: external_exports.boolean().optional()
52222
+ }).passthrough()).describe("Matching catalog entries"),
52223
+ total: external_exports.number().int().describe("Number of entries returned")
51553
52224
  }
51554
- const found = scenes.find((s2) => s2.sceneId === sceneId);
51555
- if (scenes.length > 0 && !found) {
51556
- return mcpError("usage", 2, `Scene not found: ${sceneId}`, {
51557
- subKind: "scene-not-found",
51558
- hint: "Check the sceneId with 'list_scenes' (IDs are case-sensitive).",
51559
- context: { sceneId, candidates: scenes.map((s2) => ({ sceneId: s2.sceneId, sceneName: s2.sceneName })).slice(0, 5) }
51560
- });
52225
+ },
52226
+ async ({ query, limit }) => {
52227
+ if (query.trim() === "") {
52228
+ return mcpError(
52229
+ "usage",
52230
+ 2,
52231
+ "search_catalog requires a non-empty query.",
52232
+ {
52233
+ hint: "Pass a search term like 'Bot' or 'Hub', or call list_catalog_types to enumerate all types without a query."
52234
+ }
52235
+ );
51561
52236
  }
51562
- const wouldSend = { sceneId, sceneName: found?.sceneName ?? null };
51563
- const structured2 = { ok: true, dryRun: true, wouldSend };
52237
+ const hits = searchCatalog(query, limit);
52238
+ const normalised = hits.map((e) => ({
52239
+ ...e,
52240
+ commands: e.commands.map((c) => {
52241
+ const tier = deriveSafetyTier(c, e);
52242
+ const reason = getCommandSafetyReason(c);
52243
+ return {
52244
+ ...c,
52245
+ safetyTier: tier,
52246
+ ...reason ? { safetyReason: reason } : {}
52247
+ };
52248
+ })
52249
+ }));
52250
+ const structured = { results: normalised, total: normalised.length };
51564
52251
  return {
51565
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
51566
- structuredContent: structured2
52252
+ content: [{ type: "text", text: JSON.stringify(normalised, null, 2) }],
52253
+ structuredContent: structured
51567
52254
  };
51568
52255
  }
51569
- try {
51570
- await executeScene(sceneId);
51571
- } catch (err) {
51572
- return apiErrorToMcpError(err);
51573
- }
51574
- const structured = { ok: true, sceneId };
51575
- return {
51576
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51577
- structuredContent: structured
51578
- };
51579
- }
51580
- );
51581
- server.registerTool(
51582
- "list_scenes",
51583
- {
51584
- title: "List all manual scenes",
51585
- description: "Fetch all manual scenes configured in the SwitchBot app.",
51586
- _meta: { agentSafetyTier: "read" },
51587
- inputSchema: external_exports.object({}).strict(),
51588
- outputSchema: {
51589
- scenes: external_exports.array(external_exports.object({ sceneId: external_exports.string(), sceneName: external_exports.string() }))
51590
- }
51591
- },
51592
- async () => {
51593
- const scenes = await fetchScenes();
51594
- return {
51595
- content: [{ type: "text", text: JSON.stringify(scenes, null, 2) }],
51596
- structuredContent: { scenes }
51597
- };
51598
- }
51599
- );
51600
- server.registerTool(
51601
- "search_catalog",
51602
- {
51603
- title: "Search the offline device catalog",
51604
- description: "Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.",
51605
- _meta: { agentSafetyTier: "read" },
51606
- inputSchema: external_exports.object({
51607
- query: external_exports.string().describe("Search query (matches type and aliases, case-insensitive). Must be non-empty; use list_catalog_types to enumerate instead."),
51608
- limit: external_exports.number().int().min(1).max(100).optional().default(20).describe("Max entries returned (default 20)")
51609
- }).strict(),
51610
- outputSchema: {
51611
- results: external_exports.array(external_exports.object({
51612
- type: external_exports.string(),
51613
- category: external_exports.enum(["physical", "ir"]),
51614
- commands: external_exports.array(external_exports.object({
51615
- command: external_exports.string(),
51616
- parameter: external_exports.string(),
51617
- description: external_exports.string(),
51618
- commandType: external_exports.enum(["command", "customize"]).optional(),
51619
- idempotent: external_exports.boolean().optional(),
51620
- safetyTier: external_exports.enum(["read", "mutation", "ir-fire-forget", "destructive", "maintenance"]).optional(),
51621
- safetyReason: external_exports.string().optional()
51622
- }).passthrough()),
51623
- aliases: external_exports.array(external_exports.string()).optional(),
51624
- statusFields: external_exports.array(external_exports.string()).optional(),
51625
- role: external_exports.string().optional(),
51626
- readOnly: external_exports.boolean().optional()
51627
- }).passthrough()).describe("Matching catalog entries"),
51628
- total: external_exports.number().int().describe("Number of entries returned")
51629
- }
51630
- },
51631
- async ({ query, limit }) => {
51632
- if (query.trim() === "") {
51633
- return mcpError(
51634
- "usage",
51635
- 2,
51636
- "search_catalog requires a non-empty query.",
51637
- {
51638
- hint: "Pass a search term like 'Bot' or 'Hub', or call list_catalog_types to enumerate all types without a query."
51639
- }
51640
- );
51641
- }
51642
- const hits = searchCatalog(query, limit);
51643
- const normalised = hits.map((e) => ({
51644
- ...e,
51645
- commands: e.commands.map((c) => {
51646
- const tier = deriveSafetyTier(c, e);
51647
- const reason = getCommandSafetyReason(c);
52256
+ );
52257
+ if (!skip("describe_device"))
52258
+ server.registerTool(
52259
+ "describe_device",
52260
+ {
52261
+ title: "Describe a specific device",
52262
+ description: "Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.",
52263
+ _meta: { agentSafetyTier: "read" },
52264
+ inputSchema: external_exports.object({
52265
+ deviceId: external_exports.string().describe("Device ID from list_devices"),
52266
+ live: external_exports.boolean().optional().default(false).describe("Also fetch live /status values (costs 1 extra API call)")
52267
+ }).strict(),
52268
+ outputSchema: {
52269
+ device: external_exports.object({
52270
+ device: external_exports.object({ deviceId: external_exports.string(), deviceName: external_exports.string() }).passthrough(),
52271
+ isPhysical: external_exports.boolean(),
52272
+ typeName: external_exports.string(),
52273
+ controlType: external_exports.string().nullable(),
52274
+ source: external_exports.enum(["catalog", "live", "catalog+live", "none"]),
52275
+ capabilities: external_exports.unknown().nullable(),
52276
+ suggestedActions: external_exports.array(external_exports.object({
52277
+ command: external_exports.string(),
52278
+ parameter: external_exports.string().optional(),
52279
+ description: external_exports.string()
52280
+ })).optional(),
52281
+ inheritedLocation: external_exports.object({
52282
+ family: external_exports.string().optional(),
52283
+ room: external_exports.string().optional()
52284
+ }).optional()
52285
+ }).passthrough().describe("Device metadata, catalog entry, capabilities, and optional live status")
52286
+ }
52287
+ },
52288
+ async ({ deviceId, live }) => {
52289
+ try {
52290
+ const result = await describeDevice(deviceId, { live });
51648
52291
  return {
51649
- ...c,
51650
- safetyTier: tier,
51651
- ...reason ? { safetyReason: reason } : {}
52292
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
52293
+ structuredContent: { device: toMcpDescribeShape(result) }
51652
52294
  };
51653
- })
51654
- }));
51655
- const structured = { results: normalised, total: normalised.length };
51656
- return {
51657
- content: [{ type: "text", text: JSON.stringify(normalised, null, 2) }],
51658
- structuredContent: structured
51659
- };
51660
- }
51661
- );
51662
- server.registerTool(
51663
- "describe_device",
51664
- {
51665
- title: "Describe a specific device",
51666
- description: "Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.",
51667
- _meta: { agentSafetyTier: "read" },
51668
- inputSchema: external_exports.object({
51669
- deviceId: external_exports.string().describe("Device ID from list_devices"),
51670
- live: external_exports.boolean().optional().default(false).describe("Also fetch live /status values (costs 1 extra API call)")
51671
- }).strict(),
51672
- outputSchema: {
51673
- device: external_exports.object({
51674
- device: external_exports.object({ deviceId: external_exports.string(), deviceName: external_exports.string() }).passthrough(),
51675
- isPhysical: external_exports.boolean(),
51676
- typeName: external_exports.string(),
51677
- controlType: external_exports.string().nullable(),
51678
- source: external_exports.enum(["catalog", "live", "catalog+live", "none"]),
51679
- capabilities: external_exports.unknown().nullable(),
51680
- suggestedActions: external_exports.array(external_exports.object({
51681
- command: external_exports.string(),
51682
- parameter: external_exports.string().optional(),
51683
- description: external_exports.string()
51684
- })).optional(),
51685
- inheritedLocation: external_exports.object({
51686
- family: external_exports.string().optional(),
51687
- room: external_exports.string().optional()
51688
- }).optional()
51689
- }).passthrough().describe("Device metadata, catalog entry, capabilities, and optional live status")
52295
+ } catch (err) {
52296
+ if (err instanceof DeviceNotFoundError) {
52297
+ return mcpError("runtime", 152, err.message, {
52298
+ hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).",
52299
+ context: { deviceId }
52300
+ });
52301
+ }
52302
+ return apiErrorToMcpError(err);
52303
+ }
51690
52304
  }
51691
- },
51692
- async ({ deviceId, live }) => {
51693
- try {
51694
- const result = await describeDevice(deviceId, { live });
52305
+ );
52306
+ if (!skip("aggregate_device_history"))
52307
+ server.registerTool(
52308
+ "aggregate_device_history",
52309
+ {
52310
+ title: "Aggregate device history",
52311
+ description: "Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL-recorded device history. Read-only; no network calls.",
52312
+ _meta: { agentSafetyTier: "read" },
52313
+ inputSchema: external_exports.object({
52314
+ deviceId: external_exports.string().min(1).describe("Device ID to aggregate over (must exist in ~/.switchbot/device-history/)."),
52315
+ since: external_exports.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
52316
+ from: external_exports.string().optional().describe("Range start (ISO-8601). Requires `to`."),
52317
+ to: external_exports.string().optional().describe("Range end (ISO-8601). Requires `from`."),
52318
+ metrics: external_exports.array(external_exports.string().min(1)).min(1).describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'),
52319
+ aggs: external_exports.array(external_exports.enum(ALL_AGG_FNS)).optional().describe('Aggregation functions to apply per metric (default: ["count","avg"]).'),
52320
+ bucket: external_exports.string().optional().describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'),
52321
+ maxBucketSamples: external_exports.number().int().positive().max(MAX_SAMPLE_CAP).optional().describe(`Sample cap per bucket to bound memory (default ${1e4}, max ${MAX_SAMPLE_CAP}). partial=true in the result when any bucket was capped.`)
52322
+ }).strict(),
52323
+ outputSchema: {
52324
+ deviceId: external_exports.string(),
52325
+ bucket: external_exports.string().optional().describe("Bucket width echoed back when specified; omitted for single-bucket results."),
52326
+ from: external_exports.string().describe("Effective range start (ISO-8601)."),
52327
+ to: external_exports.string().describe("Effective range end (ISO-8601)."),
52328
+ metrics: external_exports.array(external_exports.string()).describe("Metrics that were requested."),
52329
+ aggs: external_exports.array(external_exports.enum(ALL_AGG_FNS)).describe("Aggregation functions that were applied."),
52330
+ buckets: external_exports.array(
52331
+ external_exports.object({
52332
+ t: external_exports.string().describe("Bucket start timestamp (ISO-8601)."),
52333
+ metrics: external_exports.record(
52334
+ external_exports.string(),
52335
+ external_exports.object({
52336
+ count: external_exports.number().optional(),
52337
+ min: external_exports.number().optional(),
52338
+ max: external_exports.number().optional(),
52339
+ avg: external_exports.number().optional(),
52340
+ sum: external_exports.number().optional(),
52341
+ p50: external_exports.number().optional(),
52342
+ p95: external_exports.number().optional()
52343
+ }).describe("Per-aggregate function result for this metric in this bucket.")
52344
+ ).describe("Per-metric result keyed by metric name.")
52345
+ })
52346
+ ).describe("Time-ordered buckets; empty when no records match."),
52347
+ partial: external_exports.boolean().describe("True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values."),
52348
+ notes: external_exports.array(external_exports.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").')
52349
+ }
52350
+ },
52351
+ async (args) => {
52352
+ const opts = {
52353
+ since: args.since,
52354
+ from: args.from,
52355
+ to: args.to,
52356
+ metrics: args.metrics,
52357
+ aggs: args.aggs,
52358
+ bucket: args.bucket,
52359
+ maxBucketSamples: args.maxBucketSamples
52360
+ };
52361
+ const res = await aggregateDeviceHistory(args.deviceId, opts);
52362
+ const structured = {
52363
+ deviceId: res.deviceId,
52364
+ from: res.from,
52365
+ to: res.to,
52366
+ metrics: res.metrics,
52367
+ aggs: res.aggs,
52368
+ buckets: res.buckets,
52369
+ partial: res.partial,
52370
+ notes: res.notes
52371
+ };
52372
+ if (res.bucket !== void 0) structured.bucket = res.bucket;
51695
52373
  return {
51696
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
51697
- structuredContent: { device: toMcpDescribeShape(result) }
52374
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
52375
+ structuredContent: structured
51698
52376
  };
51699
- } catch (err) {
51700
- if (err instanceof DeviceNotFoundError) {
51701
- return mcpError("runtime", 152, err.message, {
51702
- hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).",
51703
- context: { deviceId }
51704
- });
51705
- }
51706
- return apiErrorToMcpError(err);
51707
52377
  }
51708
- }
51709
- );
51710
- server.registerTool(
51711
- "aggregate_device_history",
51712
- {
51713
- title: "Aggregate device history",
51714
- description: "Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL-recorded device history. Read-only; no network calls.",
51715
- _meta: { agentSafetyTier: "read" },
51716
- inputSchema: external_exports.object({
51717
- deviceId: external_exports.string().min(1).describe("Device ID to aggregate over (must exist in ~/.switchbot/device-history/)."),
51718
- since: external_exports.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
51719
- from: external_exports.string().optional().describe("Range start (ISO-8601). Requires `to`."),
51720
- to: external_exports.string().optional().describe("Range end (ISO-8601). Requires `from`."),
51721
- metrics: external_exports.array(external_exports.string().min(1)).min(1).describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'),
51722
- aggs: external_exports.array(external_exports.enum(ALL_AGG_FNS)).optional().describe('Aggregation functions to apply per metric (default: ["count","avg"]).'),
51723
- bucket: external_exports.string().optional().describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'),
51724
- maxBucketSamples: external_exports.number().int().positive().max(MAX_SAMPLE_CAP).optional().describe(`Sample cap per bucket to bound memory (default ${1e4}, max ${MAX_SAMPLE_CAP}). partial=true in the result when any bucket was capped.`)
51725
- }).strict(),
51726
- outputSchema: {
51727
- deviceId: external_exports.string(),
51728
- bucket: external_exports.string().optional().describe("Bucket width echoed back when specified; omitted for single-bucket results."),
51729
- from: external_exports.string().describe("Effective range start (ISO-8601)."),
51730
- to: external_exports.string().describe("Effective range end (ISO-8601)."),
51731
- metrics: external_exports.array(external_exports.string()).describe("Metrics that were requested."),
51732
- aggs: external_exports.array(external_exports.enum(ALL_AGG_FNS)).describe("Aggregation functions that were applied."),
51733
- buckets: external_exports.array(
51734
- external_exports.object({
51735
- t: external_exports.string().describe("Bucket start timestamp (ISO-8601)."),
51736
- metrics: external_exports.record(
51737
- external_exports.string(),
51738
- external_exports.object({
51739
- count: external_exports.number().optional(),
51740
- min: external_exports.number().optional(),
51741
- max: external_exports.number().optional(),
51742
- avg: external_exports.number().optional(),
51743
- sum: external_exports.number().optional(),
51744
- p50: external_exports.number().optional(),
51745
- p95: external_exports.number().optional()
51746
- }).describe("Per-aggregate function result for this metric in this bucket.")
51747
- ).describe("Per-metric result keyed by metric name.")
51748
- })
51749
- ).describe("Time-ordered buckets; empty when no records match."),
51750
- partial: external_exports.boolean().describe("True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values."),
51751
- notes: external_exports.array(external_exports.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").')
52378
+ );
52379
+ if (!skip("account_overview"))
52380
+ server.registerTool(
52381
+ "account_overview",
52382
+ {
52383
+ title: "Bootstrap account overview",
52384
+ description: "Get a complete account snapshot: devices, scenes, quota usage, cache status, and MQTT connection state. Use this for cold-start initialization or periodic health checks.",
52385
+ _meta: { agentSafetyTier: "read" },
52386
+ inputSchema: external_exports.object({}).strict(),
52387
+ outputSchema: {
52388
+ version: external_exports.string(),
52389
+ schemaVersion: external_exports.string(),
52390
+ devices: external_exports.array(external_exports.object({
52391
+ deviceId: external_exports.string(),
52392
+ deviceName: external_exports.string(),
52393
+ deviceType: external_exports.string().optional()
52394
+ }).passthrough()).describe("All physical devices"),
52395
+ infraredRemotes: external_exports.array(external_exports.object({
52396
+ deviceId: external_exports.string(),
52397
+ deviceName: external_exports.string(),
52398
+ remoteType: external_exports.string()
52399
+ }).passthrough()).describe("All IR remotes"),
52400
+ scenes: external_exports.array(external_exports.object({
52401
+ sceneId: external_exports.string(),
52402
+ sceneName: external_exports.string()
52403
+ }).passthrough()).describe("All manual scenes"),
52404
+ quota: external_exports.object({
52405
+ date: external_exports.string(),
52406
+ total: external_exports.number(),
52407
+ remaining: external_exports.number(),
52408
+ endpoints: external_exports.record(external_exports.string(), external_exports.number()).optional()
52409
+ }).describe("Today's quota usage"),
52410
+ cache: external_exports.object({
52411
+ list: external_exports.object({
52412
+ path: external_exports.string(),
52413
+ exists: external_exports.boolean(),
52414
+ lastUpdated: external_exports.string().optional(),
52415
+ ageMs: external_exports.number().optional(),
52416
+ deviceCount: external_exports.number().optional()
52417
+ }),
52418
+ status: external_exports.object({
52419
+ path: external_exports.string(),
52420
+ exists: external_exports.boolean(),
52421
+ entryCount: external_exports.number(),
52422
+ oldestFetchedAt: external_exports.string().optional(),
52423
+ newestFetchedAt: external_exports.string().optional()
52424
+ })
52425
+ }).describe("Cache status"),
52426
+ mqtt: external_exports.object({
52427
+ state: external_exports.string(),
52428
+ subscribers: external_exports.number()
52429
+ }).optional().describe("MQTT connection state (present when REST credentials are configured; auto-provisioned via POST /v1.1/iot/credential)")
52430
+ }
52431
+ },
52432
+ async () => {
52433
+ const deviceList = await fetchDeviceList();
52434
+ const sceneList = await fetchScenes();
52435
+ const cacheInfo = describeCache();
52436
+ const quota = todayUsage();
52437
+ const overview = {
52438
+ version: VERSION,
52439
+ schemaVersion: "1.1",
52440
+ devices: deviceList.deviceList.map(toMcpDeviceListShape),
52441
+ infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
52442
+ scenes: sceneList.map((s2) => ({
52443
+ sceneId: s2.sceneId,
52444
+ sceneName: s2.sceneName
52445
+ })),
52446
+ quota: {
52447
+ date: quota.date,
52448
+ total: quota.total,
52449
+ remaining: quota.remaining,
52450
+ endpoints: quota.endpoints
52451
+ },
52452
+ cache: {
52453
+ list: cacheInfo.list,
52454
+ status: cacheInfo.status
52455
+ },
52456
+ ...eventManager ? {
52457
+ mqtt: {
52458
+ state: eventManager.getState(),
52459
+ subscribers: eventManager.getSubscriberCount()
52460
+ }
52461
+ } : {}
52462
+ };
52463
+ return {
52464
+ content: [{
52465
+ type: "text",
52466
+ text: JSON.stringify(overview, null, 2)
52467
+ }],
52468
+ structuredContent: overview
52469
+ };
51752
52470
  }
51753
- },
51754
- async (args) => {
51755
- const opts = {
51756
- since: args.since,
51757
- from: args.from,
51758
- to: args.to,
51759
- metrics: args.metrics,
51760
- aggs: args.aggs,
51761
- bucket: args.bucket,
51762
- maxBucketSamples: args.maxBucketSamples
51763
- };
51764
- const res = await aggregateDeviceHistory(args.deviceId, opts);
51765
- const structured = {
51766
- deviceId: res.deviceId,
51767
- from: res.from,
51768
- to: res.to,
51769
- metrics: res.metrics,
51770
- aggs: res.aggs,
51771
- buckets: res.buckets,
51772
- partial: res.partial,
51773
- notes: res.notes
51774
- };
51775
- if (res.bucket !== void 0) structured.bucket = res.bucket;
51776
- return {
51777
- content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
51778
- structuredContent: structured
51779
- };
51780
- }
51781
- );
51782
- server.registerTool(
51783
- "account_overview",
51784
- {
51785
- title: "Bootstrap account overview",
51786
- description: "Get a complete account snapshot: devices, scenes, quota usage, cache status, and MQTT connection state. Use this for cold-start initialization or periodic health checks.",
51787
- _meta: { agentSafetyTier: "read" },
51788
- inputSchema: external_exports.object({}).strict(),
51789
- outputSchema: {
51790
- version: external_exports.string(),
51791
- schemaVersion: external_exports.string(),
51792
- devices: external_exports.array(external_exports.object({
51793
- deviceId: external_exports.string(),
51794
- deviceName: external_exports.string(),
51795
- deviceType: external_exports.string().optional()
51796
- }).passthrough()).describe("All physical devices"),
51797
- infraredRemotes: external_exports.array(external_exports.object({
51798
- deviceId: external_exports.string(),
51799
- deviceName: external_exports.string(),
51800
- remoteType: external_exports.string()
51801
- }).passthrough()).describe("All IR remotes"),
51802
- scenes: external_exports.array(external_exports.object({
51803
- sceneId: external_exports.string(),
51804
- sceneName: external_exports.string()
51805
- }).passthrough()).describe("All manual scenes"),
51806
- quota: external_exports.object({
51807
- date: external_exports.string(),
51808
- total: external_exports.number(),
51809
- remaining: external_exports.number(),
51810
- endpoints: external_exports.record(external_exports.string(), external_exports.number()).optional()
51811
- }).describe("Today's quota usage"),
51812
- cache: external_exports.object({
51813
- list: external_exports.object({
51814
- path: external_exports.string(),
51815
- exists: external_exports.boolean(),
51816
- lastUpdated: external_exports.string().optional(),
51817
- ageMs: external_exports.number().optional(),
51818
- deviceCount: external_exports.number().optional()
51819
- }),
51820
- status: external_exports.object({
52471
+ );
52472
+ if (!skip("policy_validate"))
52473
+ server.registerTool(
52474
+ "policy_validate",
52475
+ {
52476
+ title: "Validate a policy.yaml file",
52477
+ description: "Check a policy file against the embedded JSON Schema, offline command/device semantics, and local safety guards. By default this stays offline; set live=true to resolve aliases and rule targets against the current account inventory. It still does not verify commands against live capabilities, current firmware, or other runtime-only device behavior. When no path is given, reads the resolved default (${SWITCHBOT_POLICY_PATH} or ~/.config/openclaw/switchbot/policy.yaml). Use before relying on aliases/quiet_hours/confirmations so the agent never acts on a broken policy.",
52478
+ _meta: { agentSafetyTier: "read" },
52479
+ inputSchema: external_exports.object({
52480
+ path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
52481
+ live: external_exports.boolean().optional().describe("When true, also resolve aliases and rule targets against the current account inventory")
52482
+ }).strict(),
52483
+ outputSchema: {
52484
+ policyPath: external_exports.string(),
52485
+ schemaVersion: external_exports.string(),
52486
+ validationScope: external_exports.string(),
52487
+ limitations: external_exports.array(external_exports.string()),
52488
+ present: external_exports.boolean().describe("false when the file does not exist"),
52489
+ valid: external_exports.boolean().nullable().describe("null when present=false"),
52490
+ errors: external_exports.array(external_exports.object({
51821
52491
  path: external_exports.string(),
51822
- exists: external_exports.boolean(),
51823
- entryCount: external_exports.number(),
51824
- oldestFetchedAt: external_exports.string().optional(),
51825
- newestFetchedAt: external_exports.string().optional()
51826
- })
51827
- }).describe("Cache status"),
51828
- mqtt: external_exports.object({
51829
- state: external_exports.string(),
51830
- subscribers: external_exports.number()
51831
- }).optional().describe("MQTT connection state (present when REST credentials are configured; auto-provisioned via POST /v1.1/iot/credential)")
51832
- }
51833
- },
51834
- async () => {
51835
- const deviceList = await fetchDeviceList();
51836
- const sceneList = await fetchScenes();
51837
- const cacheInfo = describeCache();
51838
- const quota = todayUsage();
51839
- const overview = {
51840
- version: VERSION,
51841
- schemaVersion: "1.1",
51842
- devices: deviceList.deviceList.map(toMcpDeviceListShape),
51843
- infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
51844
- scenes: sceneList.map((s2) => ({
51845
- sceneId: s2.sceneId,
51846
- sceneName: s2.sceneName
51847
- })),
51848
- quota: {
51849
- date: quota.date,
51850
- total: quota.total,
51851
- remaining: quota.remaining,
51852
- endpoints: quota.endpoints
51853
- },
51854
- cache: {
51855
- list: cacheInfo.list,
51856
- status: cacheInfo.status
51857
- },
51858
- ...eventManager ? {
51859
- mqtt: {
51860
- state: eventManager.getState(),
51861
- subscribers: eventManager.getSubscriberCount()
52492
+ line: external_exports.number().optional(),
52493
+ col: external_exports.number().optional(),
52494
+ keyword: external_exports.string(),
52495
+ message: external_exports.string(),
52496
+ hint: external_exports.string().optional(),
52497
+ schemaPath: external_exports.string()
52498
+ })).describe("Empty when valid or when the file is missing")
52499
+ }
52500
+ },
52501
+ async ({ path: pathArg, live }) => {
52502
+ const policyPath = resolvePolicyPath({ flag: pathArg });
52503
+ try {
52504
+ const loaded = loadPolicyFile(policyPath);
52505
+ let result = validateLoadedPolicy(loaded);
52506
+ if (live) {
52507
+ if (!tryLoadConfig()) {
52508
+ return mcpError("runtime", 151, "policy_validate live=true requires configured SwitchBot credentials.", {
52509
+ hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
52510
+ });
52511
+ }
52512
+ const inventory = await fetchDeviceList(void 0, { bypassCache: true });
52513
+ result = validateLoadedPolicyAgainstInventory(loaded, inventory);
51862
52514
  }
51863
- } : {}
51864
- };
51865
- return {
51866
- content: [{
51867
- type: "text",
51868
- text: JSON.stringify(overview, null, 2)
51869
- }],
51870
- structuredContent: overview
51871
- };
51872
- }
51873
- );
51874
- server.registerTool(
51875
- "policy_validate",
51876
- {
51877
- title: "Validate a policy.yaml file",
51878
- description: "Check a policy file against the embedded JSON Schema, offline command/device semantics, and local safety guards. By default this stays offline; set live=true to resolve aliases and rule targets against the current account inventory. It still does not verify commands against live capabilities, current firmware, or other runtime-only device behavior. When no path is given, reads the resolved default (${SWITCHBOT_POLICY_PATH} or ~/.config/openclaw/switchbot/policy.yaml). Use before relying on aliases/quiet_hours/confirmations so the agent never acts on a broken policy.",
51879
- _meta: { agentSafetyTier: "read" },
51880
- inputSchema: external_exports.object({
51881
- path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
51882
- live: external_exports.boolean().optional().describe("When true, also resolve aliases and rule targets against the current account inventory")
51883
- }).strict(),
51884
- outputSchema: {
51885
- policyPath: external_exports.string(),
51886
- schemaVersion: external_exports.string(),
51887
- validationScope: external_exports.string(),
51888
- limitations: external_exports.array(external_exports.string()),
51889
- present: external_exports.boolean().describe("false when the file does not exist"),
51890
- valid: external_exports.boolean().nullable().describe("null when present=false"),
51891
- errors: external_exports.array(external_exports.object({
51892
- path: external_exports.string(),
51893
- line: external_exports.number().optional(),
51894
- col: external_exports.number().optional(),
51895
- keyword: external_exports.string(),
51896
- message: external_exports.string(),
51897
- hint: external_exports.string().optional(),
51898
- schemaPath: external_exports.string()
51899
- })).describe("Empty when valid or when the file is missing")
51900
- }
51901
- },
51902
- async ({ path: pathArg, live }) => {
51903
- const policyPath = resolvePolicyPath({ flag: pathArg });
51904
- try {
51905
- const loaded = loadPolicyFile(policyPath);
51906
- let result = validateLoadedPolicy(loaded);
51907
- if (live) {
51908
- if (!tryLoadConfig()) {
51909
- return mcpError("runtime", 151, "policy_validate live=true requires configured SwitchBot credentials.", {
51910
- hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
51911
- });
52515
+ const structured = {
52516
+ policyPath: result.policyPath,
52517
+ schemaVersion: result.schemaVersion,
52518
+ validationScope: result.validationScope,
52519
+ limitations: result.limitations,
52520
+ present: true,
52521
+ valid: result.valid,
52522
+ errors: result.errors
52523
+ };
52524
+ return {
52525
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52526
+ structuredContent: structured
52527
+ };
52528
+ } catch (err) {
52529
+ if (err instanceof PolicyFileNotFoundError) {
52530
+ const structured = {
52531
+ policyPath,
52532
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
52533
+ validationScope: "schema+offline-semantics",
52534
+ limitations: [
52535
+ "Does not resolve aliases against the live device inventory.",
52536
+ "Does not verify commands against the real target device, live capabilities, or current firmware."
52537
+ ],
52538
+ present: false,
52539
+ valid: null,
52540
+ errors: []
52541
+ };
52542
+ return {
52543
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52544
+ structuredContent: structured
52545
+ };
52546
+ }
52547
+ if (err instanceof PolicyYamlParseError) {
52548
+ const structured = {
52549
+ policyPath,
52550
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
52551
+ validationScope: "schema+offline-semantics",
52552
+ limitations: [
52553
+ "Does not resolve aliases against the live device inventory.",
52554
+ "Does not verify commands against the real target device, live capabilities, or current firmware."
52555
+ ],
52556
+ present: true,
52557
+ valid: false,
52558
+ errors: err.yamlErrors.map((e) => ({
52559
+ path: "",
52560
+ line: e.line,
52561
+ col: e.col,
52562
+ keyword: "yaml-parse",
52563
+ message: e.message,
52564
+ schemaPath: ""
52565
+ }))
52566
+ };
52567
+ return {
52568
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52569
+ structuredContent: structured
52570
+ };
51912
52571
  }
51913
- const inventory = await fetchDeviceList(void 0, { bypassCache: true });
51914
- result = validateLoadedPolicyAgainstInventory(loaded, inventory);
52572
+ throw err;
51915
52573
  }
52574
+ }
52575
+ );
52576
+ if (!skip("policy_new"))
52577
+ server.registerTool(
52578
+ "policy_new",
52579
+ {
52580
+ title: "Scaffold a starter policy.yaml",
52581
+ description: "Write a starter policy file to the resolved default path (or a given path). Refuses to overwrite unless force=true. This is a write action: the agent should only call it after confirming with the user.",
52582
+ _meta: { agentSafetyTier: "action" },
52583
+ inputSchema: external_exports.object({
52584
+ path: external_exports.string().optional().describe("Optional target path; defaults to the resolved default"),
52585
+ force: external_exports.boolean().optional().describe("When true, overwrite an existing file")
52586
+ }).strict(),
52587
+ outputSchema: {
52588
+ policyPath: external_exports.string(),
52589
+ schemaVersion: external_exports.string(),
52590
+ bytesWritten: external_exports.number(),
52591
+ overwritten: external_exports.boolean()
52592
+ }
52593
+ },
52594
+ async ({ path: pathArg, force }) => {
52595
+ const policyPath = resolvePolicyPath({ flag: pathArg });
52596
+ const doForce = force === true;
52597
+ if (fs18.existsSync(policyPath) && !doForce) {
52598
+ return mcpError("guard", 5, `refusing to overwrite existing policy at ${policyPath}`, {
52599
+ hint: "pass force=true to overwrite, or choose a different path",
52600
+ context: { policyPath }
52601
+ });
52602
+ }
52603
+ const template = readPolicyExampleYaml();
52604
+ fs18.mkdirSync(pathDirname(policyPath), { recursive: true });
52605
+ fs18.writeFileSync(policyPath, template, { encoding: "utf-8" });
51916
52606
  const structured = {
51917
- policyPath: result.policyPath,
51918
- schemaVersion: result.schemaVersion,
51919
- validationScope: result.validationScope,
51920
- limitations: result.limitations,
51921
- present: true,
51922
- valid: result.valid,
51923
- errors: result.errors
52607
+ policyPath,
52608
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
52609
+ bytesWritten: Buffer.byteLength(template, "utf-8"),
52610
+ overwritten: doForce
51924
52611
  };
51925
52612
  return {
51926
52613
  content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51927
52614
  structuredContent: structured
51928
52615
  };
51929
- } catch (err) {
51930
- if (err instanceof PolicyFileNotFoundError) {
51931
- const structured = {
51932
- policyPath,
51933
- schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
51934
- validationScope: "schema+offline-semantics",
51935
- limitations: [
51936
- "Does not resolve aliases against the live device inventory.",
51937
- "Does not verify commands against the real target device, live capabilities, or current firmware."
51938
- ],
51939
- present: false,
51940
- valid: null,
51941
- errors: []
52616
+ }
52617
+ );
52618
+ if (!skip("policy_migrate"))
52619
+ server.registerTool(
52620
+ "policy_migrate",
52621
+ {
52622
+ title: "Migrate a policy file to the latest supported schema",
52623
+ description: 'Rewrites a policy file between schema versions this CLI still supports while preserving comments. Safe by default: if the migrated document would fail schema validation, the file is NOT rewritten and the tool returns status="precheck-failed" with the list of errors. Pass dryRun=true to preview without touching the file. This release only supports v0.2, so legacy v0.1 files are reported as unsupported rather than migrated.',
52624
+ _meta: { agentSafetyTier: "action" },
52625
+ inputSchema: external_exports.object({
52626
+ path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
52627
+ dryRun: external_exports.boolean().optional().describe("When true, report what would change without writing"),
52628
+ to: external_exports.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`)
52629
+ }).strict(),
52630
+ outputSchema: {
52631
+ policyPath: external_exports.string(),
52632
+ fileVersion: external_exports.string().optional(),
52633
+ targetVersion: external_exports.string(),
52634
+ supportedVersions: external_exports.array(external_exports.string()),
52635
+ status: external_exports.enum([
52636
+ "already-current",
52637
+ "migrated",
52638
+ "dry-run",
52639
+ "no-version-field",
52640
+ "unsupported",
52641
+ "precheck-failed",
52642
+ "file-not-found"
52643
+ ]),
52644
+ from: external_exports.string().optional(),
52645
+ to: external_exports.string().optional(),
52646
+ bytesWritten: external_exports.number().optional(),
52647
+ message: external_exports.string(),
52648
+ errors: external_exports.array(external_exports.object({ path: external_exports.string(), keyword: external_exports.string(), message: external_exports.string() })).optional()
52649
+ }
52650
+ },
52651
+ async ({ path: pathArg, dryRun, to }) => {
52652
+ const policyPath = resolvePolicyPath({ flag: pathArg });
52653
+ const target = to ?? LATEST_SUPPORTED_VERSION;
52654
+ let loaded;
52655
+ try {
52656
+ loaded = loadPolicyFile(policyPath);
52657
+ } catch (err) {
52658
+ if (err instanceof PolicyFileNotFoundError) {
52659
+ const structured2 = {
52660
+ policyPath,
52661
+ targetVersion: target,
52662
+ supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
52663
+ status: "file-not-found",
52664
+ message: `policy file not found: ${policyPath}`
52665
+ };
52666
+ return {
52667
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52668
+ structuredContent: structured2
52669
+ };
52670
+ }
52671
+ throw err;
52672
+ }
52673
+ const data = loaded.data;
52674
+ const fileVersion = typeof data?.version === "string" ? data.version : void 0;
52675
+ const base = {
52676
+ policyPath,
52677
+ fileVersion,
52678
+ targetVersion: target,
52679
+ supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS]
52680
+ };
52681
+ if (!fileVersion) {
52682
+ const structured2 = {
52683
+ ...base,
52684
+ status: "no-version-field",
52685
+ message: `policy has no \`version\` field \u2014 add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\``
51942
52686
  };
51943
52687
  return {
51944
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51945
- structuredContent: structured
52688
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52689
+ structuredContent: structured2
51946
52690
  };
51947
52691
  }
51948
- if (err instanceof PolicyYamlParseError) {
51949
- const structured = {
51950
- policyPath,
51951
- schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
51952
- validationScope: "schema+offline-semantics",
51953
- limitations: [
51954
- "Does not resolve aliases against the live device inventory.",
51955
- "Does not verify commands against the real target device, live capabilities, or current firmware."
51956
- ],
51957
- present: true,
51958
- valid: false,
51959
- errors: err.yamlErrors.map((e) => ({
51960
- path: "",
51961
- line: e.line,
51962
- col: e.col,
51963
- keyword: "yaml-parse",
51964
- message: e.message,
51965
- schemaPath: ""
51966
- }))
52692
+ if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
52693
+ const isLegacy = fileVersion === "0.1";
52694
+ const structured2 = {
52695
+ ...base,
52696
+ status: "unsupported",
52697
+ message: isLegacy ? `policy schema v${fileVersion} is legacy and cannot be migrated by this CLI` : `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`
51967
52698
  };
51968
52699
  return {
51969
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51970
- structuredContent: structured
52700
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52701
+ structuredContent: structured2
51971
52702
  };
51972
52703
  }
51973
- throw err;
51974
- }
51975
- }
51976
- );
51977
- server.registerTool(
51978
- "policy_new",
51979
- {
51980
- title: "Scaffold a starter policy.yaml",
51981
- description: "Write a starter policy file to the resolved default path (or a given path). Refuses to overwrite unless force=true. This is a write action: the agent should only call it after confirming with the user.",
51982
- _meta: { agentSafetyTier: "action" },
51983
- inputSchema: external_exports.object({
51984
- path: external_exports.string().optional().describe("Optional target path; defaults to the resolved default"),
51985
- force: external_exports.boolean().optional().describe("When true, overwrite an existing file")
51986
- }).strict(),
51987
- outputSchema: {
51988
- policyPath: external_exports.string(),
51989
- schemaVersion: external_exports.string(),
51990
- bytesWritten: external_exports.number(),
51991
- overwritten: external_exports.boolean()
51992
- }
51993
- },
51994
- async ({ path: pathArg, force }) => {
51995
- const policyPath = resolvePolicyPath({ flag: pathArg });
51996
- const doForce = force === true;
51997
- if (fs18.existsSync(policyPath) && !doForce) {
51998
- return mcpError("guard", 5, `refusing to overwrite existing policy at ${policyPath}`, {
51999
- hint: "pass force=true to overwrite, or choose a different path",
52000
- context: { policyPath }
52001
- });
52002
- }
52003
- const template = readPolicyExampleYaml();
52004
- fs18.mkdirSync(pathDirname(policyPath), { recursive: true });
52005
- fs18.writeFileSync(policyPath, template, { encoding: "utf-8" });
52006
- const structured = {
52007
- policyPath,
52008
- schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
52009
- bytesWritten: Buffer.byteLength(template, "utf-8"),
52010
- overwritten: doForce
52011
- };
52012
- return {
52013
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52014
- structuredContent: structured
52015
- };
52016
- }
52017
- );
52018
- server.registerTool(
52019
- "policy_migrate",
52020
- {
52021
- title: "Migrate a policy file to the latest supported schema",
52022
- description: 'Rewrites a policy file between schema versions this CLI still supports while preserving comments. Safe by default: if the migrated document would fail schema validation, the file is NOT rewritten and the tool returns status="precheck-failed" with the list of errors. Pass dryRun=true to preview without touching the file. This release only supports v0.2, so legacy v0.1 files are reported as unsupported rather than migrated.',
52023
- _meta: { agentSafetyTier: "action" },
52024
- inputSchema: external_exports.object({
52025
- path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
52026
- dryRun: external_exports.boolean().optional().describe("When true, report what would change without writing"),
52027
- to: external_exports.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`)
52028
- }).strict(),
52029
- outputSchema: {
52030
- policyPath: external_exports.string(),
52031
- fileVersion: external_exports.string().optional(),
52032
- targetVersion: external_exports.string(),
52033
- supportedVersions: external_exports.array(external_exports.string()),
52034
- status: external_exports.enum([
52035
- "already-current",
52036
- "migrated",
52037
- "dry-run",
52038
- "no-version-field",
52039
- "unsupported",
52040
- "precheck-failed",
52041
- "file-not-found"
52042
- ]),
52043
- from: external_exports.string().optional(),
52044
- to: external_exports.string().optional(),
52045
- bytesWritten: external_exports.number().optional(),
52046
- message: external_exports.string(),
52047
- errors: external_exports.array(external_exports.object({ path: external_exports.string(), keyword: external_exports.string(), message: external_exports.string() })).optional()
52048
- }
52049
- },
52050
- async ({ path: pathArg, dryRun, to }) => {
52051
- const policyPath = resolvePolicyPath({ flag: pathArg });
52052
- const target = to ?? LATEST_SUPPORTED_VERSION;
52053
- let loaded;
52054
- try {
52055
- loaded = loadPolicyFile(policyPath);
52056
- } catch (err) {
52057
- if (err instanceof PolicyFileNotFoundError) {
52704
+ if (fileVersion === target) {
52058
52705
  const structured2 = {
52059
- policyPath,
52060
- targetVersion: target,
52061
- supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
52062
- status: "file-not-found",
52063
- message: `policy file not found: ${policyPath}`
52706
+ ...base,
52707
+ status: "already-current",
52708
+ message: `already on schema v${target}; no migration needed`,
52709
+ bytesWritten: 0
52064
52710
  };
52065
52711
  return {
52066
52712
  content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52067
52713
  structuredContent: structured2
52068
52714
  };
52069
52715
  }
52070
- throw err;
52071
- }
52072
- const data = loaded.data;
52073
- const fileVersion = typeof data?.version === "string" ? data.version : void 0;
52074
- const base = {
52075
- policyPath,
52076
- fileVersion,
52077
- targetVersion: target,
52078
- supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS]
52079
- };
52080
- if (!fileVersion) {
52081
- const structured2 = {
52082
- ...base,
52083
- status: "no-version-field",
52084
- message: `policy has no \`version\` field \u2014 add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\``
52085
- };
52086
- return {
52087
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52088
- structuredContent: structured2
52089
- };
52090
- }
52091
- if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
52092
- const isLegacy = fileVersion === "0.1";
52093
- const structured2 = {
52094
- ...base,
52095
- status: "unsupported",
52096
- message: isLegacy ? `policy schema v${fileVersion} is legacy and cannot be migrated by this CLI` : `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`
52097
- };
52098
- return {
52099
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52100
- structuredContent: structured2
52101
- };
52102
- }
52103
- if (fileVersion === target) {
52104
- const structured2 = {
52105
- ...base,
52106
- status: "already-current",
52107
- message: `already on schema v${target}; no migration needed`,
52108
- bytesWritten: 0
52109
- };
52110
- return {
52111
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52112
- structuredContent: structured2
52113
- };
52114
- }
52115
- const plan = planMigration(loaded, fileVersion, target);
52116
- if (!plan.precheck.valid) {
52117
- const structured2 = {
52118
- ...base,
52119
- status: "precheck-failed",
52120
- message: `migrated policy fails schema v${target} precheck; file not written`,
52121
- errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message }))
52122
- };
52123
- return {
52124
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52125
- structuredContent: structured2
52126
- };
52127
- }
52128
- const bytes = Buffer.byteLength(plan.nextSource, "utf-8");
52129
- if (dryRun) {
52130
- const structured2 = {
52716
+ const plan = planMigration(loaded, fileVersion, target);
52717
+ if (!plan.precheck.valid) {
52718
+ const structured2 = {
52719
+ ...base,
52720
+ status: "precheck-failed",
52721
+ message: `migrated policy fails schema v${target} precheck; file not written`,
52722
+ errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message }))
52723
+ };
52724
+ return {
52725
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52726
+ structuredContent: structured2
52727
+ };
52728
+ }
52729
+ const bytes = Buffer.byteLength(plan.nextSource, "utf-8");
52730
+ if (dryRun) {
52731
+ const structured2 = {
52732
+ ...base,
52733
+ status: "dry-run",
52734
+ from: plan.fromVersion,
52735
+ to: plan.toVersion,
52736
+ bytesWritten: 0,
52737
+ message: `dry-run: would upgrade v${plan.fromVersion} \u2192 v${plan.toVersion} (${bytes} bytes)`
52738
+ };
52739
+ return {
52740
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52741
+ structuredContent: structured2
52742
+ };
52743
+ }
52744
+ writeFileSync(policyPath, plan.nextSource, { encoding: "utf-8" });
52745
+ const structured = {
52131
52746
  ...base,
52132
- status: "dry-run",
52747
+ status: "migrated",
52133
52748
  from: plan.fromVersion,
52134
52749
  to: plan.toVersion,
52135
- bytesWritten: 0,
52136
- message: `dry-run: would upgrade v${plan.fromVersion} \u2192 v${plan.toVersion} (${bytes} bytes)`
52750
+ bytesWritten: bytes,
52751
+ message: `migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`
52137
52752
  };
52138
52753
  return {
52139
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52140
- structuredContent: structured2
52754
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52755
+ structuredContent: structured
52141
52756
  };
52142
52757
  }
52143
- writeFileSync(policyPath, plan.nextSource, { encoding: "utf-8" });
52144
- const structured = {
52145
- ...base,
52146
- status: "migrated",
52147
- from: plan.fromVersion,
52148
- to: plan.toVersion,
52149
- bytesWritten: bytes,
52150
- message: `migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`
52151
- };
52152
- return {
52153
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52154
- structuredContent: structured
52155
- };
52156
- }
52157
- );
52158
- server.registerTool(
52159
- "policy_diff",
52160
- {
52161
- title: "Compare two policy files",
52162
- description: "Compare two policy YAML files and return the same contract as `switchbot --json policy diff`: { leftPath, rightPath, equal, changeCount, truncated, stats, changes, diff }.",
52163
- _meta: { agentSafetyTier: "read" },
52164
- inputSchema: external_exports.object({
52165
- left_path: external_exports.string().min(1).describe("Path to the baseline policy file."),
52166
- right_path: external_exports.string().min(1).describe("Path to the candidate policy file.")
52167
- }).strict(),
52168
- outputSchema: {
52169
- leftPath: external_exports.string(),
52170
- rightPath: external_exports.string(),
52171
- equal: external_exports.boolean(),
52172
- changeCount: external_exports.number().int(),
52173
- truncated: external_exports.boolean(),
52174
- stats: external_exports.object({
52175
- added: external_exports.number().int(),
52176
- removed: external_exports.number().int(),
52177
- changed: external_exports.number().int()
52178
- }),
52179
- changes: external_exports.array(external_exports.object({
52180
- path: external_exports.string(),
52181
- kind: external_exports.enum(["added", "removed", "changed"]),
52182
- before: external_exports.unknown().optional(),
52183
- after: external_exports.unknown().optional()
52184
- })),
52185
- diff: external_exports.string()
52186
- }
52187
- },
52188
- ({ left_path, right_path }) => {
52189
- let leftSource = "";
52190
- let rightSource = "";
52191
- try {
52192
- leftSource = fs18.readFileSync(left_path, "utf-8");
52193
- } catch (err) {
52194
- if (err?.code === "ENOENT") {
52195
- return mcpError("usage", 2, `policy file not found: ${left_path}`, {
52196
- context: { policyPath: left_path }
52197
- });
52758
+ );
52759
+ if (!skip("policy_diff"))
52760
+ server.registerTool(
52761
+ "policy_diff",
52762
+ {
52763
+ title: "Compare two policy files",
52764
+ description: "Compare two policy YAML files and return the same contract as `switchbot --json policy diff`: { leftPath, rightPath, equal, changeCount, truncated, stats, changes, diff }.",
52765
+ _meta: { agentSafetyTier: "read" },
52766
+ inputSchema: external_exports.object({
52767
+ left_path: external_exports.string().min(1).describe("Path to the baseline policy file."),
52768
+ right_path: external_exports.string().min(1).describe("Path to the candidate policy file.")
52769
+ }).strict(),
52770
+ outputSchema: {
52771
+ leftPath: external_exports.string(),
52772
+ rightPath: external_exports.string(),
52773
+ equal: external_exports.boolean(),
52774
+ changeCount: external_exports.number().int(),
52775
+ truncated: external_exports.boolean(),
52776
+ stats: external_exports.object({
52777
+ added: external_exports.number().int(),
52778
+ removed: external_exports.number().int(),
52779
+ changed: external_exports.number().int()
52780
+ }),
52781
+ changes: external_exports.array(external_exports.object({
52782
+ path: external_exports.string(),
52783
+ kind: external_exports.enum(["added", "removed", "changed"]),
52784
+ before: external_exports.unknown().optional(),
52785
+ after: external_exports.unknown().optional()
52786
+ })),
52787
+ diff: external_exports.string()
52198
52788
  }
52199
- return mcpError("runtime", 1, `failed to read ${left_path}: ${String(err)}`);
52200
- }
52201
- try {
52202
- rightSource = fs18.readFileSync(right_path, "utf-8");
52203
- } catch (err) {
52204
- if (err?.code === "ENOENT") {
52205
- return mcpError("usage", 2, `policy file not found: ${right_path}`, {
52206
- context: { policyPath: right_path }
52207
- });
52789
+ },
52790
+ ({ left_path, right_path }) => {
52791
+ let leftSource = "";
52792
+ let rightSource = "";
52793
+ try {
52794
+ leftSource = fs18.readFileSync(left_path, "utf-8");
52795
+ } catch (err) {
52796
+ if (err?.code === "ENOENT") {
52797
+ return mcpError("usage", 2, `policy file not found: ${left_path}`, {
52798
+ context: { policyPath: left_path }
52799
+ });
52800
+ }
52801
+ return mcpError("runtime", 1, `failed to read ${left_path}: ${String(err)}`);
52208
52802
  }
52209
- return mcpError("runtime", 1, `failed to read ${right_path}: ${String(err)}`);
52210
- }
52211
- let leftDoc;
52212
- let rightDoc;
52213
- try {
52214
- leftDoc = (0, import_yaml7.parse)(leftSource);
52215
- } catch (err) {
52216
- return mcpError("usage", 2, `YAML parse error in ${left_path}: ${err.message}`);
52217
- }
52218
- try {
52219
- rightDoc = (0, import_yaml7.parse)(rightSource);
52220
- } catch (err) {
52221
- return mcpError("usage", 2, `YAML parse error in ${right_path}: ${err.message}`);
52803
+ try {
52804
+ rightSource = fs18.readFileSync(right_path, "utf-8");
52805
+ } catch (err) {
52806
+ if (err?.code === "ENOENT") {
52807
+ return mcpError("usage", 2, `policy file not found: ${right_path}`, {
52808
+ context: { policyPath: right_path }
52809
+ });
52810
+ }
52811
+ return mcpError("runtime", 1, `failed to read ${right_path}: ${String(err)}`);
52812
+ }
52813
+ let leftDoc;
52814
+ let rightDoc;
52815
+ try {
52816
+ leftDoc = (0, import_yaml7.parse)(leftSource);
52817
+ } catch (err) {
52818
+ return mcpError("usage", 2, `YAML parse error in ${left_path}: ${err.message}`);
52819
+ }
52820
+ try {
52821
+ rightDoc = (0, import_yaml7.parse)(rightSource);
52822
+ } catch (err) {
52823
+ return mcpError("usage", 2, `YAML parse error in ${right_path}: ${err.message}`);
52824
+ }
52825
+ const result = {
52826
+ leftPath: left_path,
52827
+ rightPath: right_path,
52828
+ ...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource)
52829
+ };
52830
+ return {
52831
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
52832
+ structuredContent: result
52833
+ };
52222
52834
  }
52223
- const result = {
52224
- leftPath: left_path,
52225
- rightPath: right_path,
52226
- ...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource)
52227
- };
52228
- return {
52229
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
52230
- structuredContent: result
52231
- };
52232
- }
52233
- );
52835
+ );
52234
52836
  if (eventManager) {
52235
52837
  server.registerResource(
52236
52838
  "events",
@@ -52253,559 +52855,568 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
52253
52855
  }
52254
52856
  );
52255
52857
  }
52256
- server.registerTool(
52257
- "plan_suggest",
52258
- {
52259
- title: "Draft a SwitchBot execution plan from intent",
52260
- description: "Generate a candidate Plan JSON from a natural language intent and a list of device IDs. Uses keyword heuristics (no LLM) to pick the command. The returned plan is ready to pass to `plan run` \u2014 review and edit before executing. Recognised commands: turnOn, turnOff, press, lock, unlock, open, close, pause. Falls back to turnOn with a warning when intent is unclear.",
52261
- _meta: { agentSafetyTier: "read" },
52262
- inputSchema: external_exports.object({
52263
- intent: external_exports.string().min(1).describe('Natural language description of what to do (e.g. "turn off all lights").'),
52264
- device_ids: external_exports.array(external_exports.string().min(1)).min(1).describe("Device IDs to act on.")
52265
- }).strict(),
52266
- outputSchema: {
52267
- plan: external_exports.unknown().describe("Candidate Plan JSON (version 1.0) ready to pass to plan run."),
52268
- warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted to turnOn).")
52269
- }
52270
- },
52271
- ({ intent, device_ids }) => {
52272
- const devices = device_ids.map((id) => {
52273
- const cached2 = getCachedDevice(id);
52274
- return { id, name: cached2?.name, type: cached2?.type };
52275
- });
52276
- try {
52277
- const { plan, warnings } = suggestPlan({ intent, devices });
52278
- return {
52279
- content: [{ type: "text", text: JSON.stringify({ plan, warnings }, null, 2) }],
52280
- structuredContent: { plan, warnings }
52281
- };
52282
- } catch (err) {
52283
- return apiErrorToMcpError(err);
52284
- }
52285
- }
52286
- );
52287
- server.registerTool(
52288
- "plan_run",
52289
- {
52290
- title: "Validate and execute a SwitchBot plan",
52291
- description: "Execute a Plan JSON object (version 1.0). Destructive command steps are skipped unless yes=true, and the default safety profile still refuses direct destructive execution in favor of the reviewed plan workflow. Scene and wait steps run in order. Returns per-step results and a summary.",
52292
- _meta: { agentSafetyTier: "action" },
52293
- inputSchema: external_exports.object({
52294
- plan: external_exports.unknown().describe("Plan JSON object (same schema as `switchbot plan run`)."),
52295
- yes: external_exports.boolean().optional().describe("Authorize destructive command steps."),
52296
- continue_on_error: external_exports.boolean().optional().describe("Keep executing later steps after a failed step.")
52297
- }).strict(),
52298
- outputSchema: {
52299
- ran: external_exports.boolean(),
52300
- plan: external_exports.unknown(),
52301
- results: external_exports.array(external_exports.unknown()),
52302
- summary: external_exports.object({
52303
- total: external_exports.number().int(),
52304
- ok: external_exports.number().int(),
52305
- error: external_exports.number().int(),
52306
- skipped: external_exports.number().int()
52307
- })
52308
- }
52309
- },
52310
- async ({ plan, yes, continue_on_error }) => {
52311
- const validated = validatePlan(plan);
52312
- if (!validated.ok) {
52313
- return mcpError("usage", 2, "plan invalid", {
52314
- context: { issues: validated.issues },
52315
- hint: "Fix the reported issues and retry plan_run."
52316
- });
52317
- }
52318
- const out = {
52319
- ran: true,
52320
- plan: validated.plan,
52321
- results: [],
52322
- summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 }
52323
- };
52324
- const continueOnError = continue_on_error === true;
52325
- const allowDestructive = yes === true;
52326
- const destructiveSteps = validated.plan.steps.map((step, index) => ({ step, index })).filter((entry) => entry.step.type === "command").map(({ step, index }) => {
52327
- const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
52328
- const commandType = step.commandType ?? "command";
52329
- const deviceType = getCachedDevice(resolvedDeviceId)?.type;
52330
- return {
52331
- index: index + 1,
52332
- deviceId: resolvedDeviceId,
52333
- command: step.command,
52334
- commandType,
52335
- deviceType: deviceType ?? null,
52336
- destructive: isDestructiveCommand(deviceType, step.command, commandType)
52337
- };
52338
- }).filter((step) => step.destructive);
52339
- if (allowDestructive && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
52340
- return mcpError("guard", 3, "Direct destructive execution is disabled for plan_run.", {
52341
- hint: destructiveExecutionHint(),
52342
- context: {
52343
- destructiveSteps: destructiveSteps.map((step) => ({
52344
- step: step.index,
52345
- deviceId: step.deviceId,
52346
- deviceType: step.deviceType,
52347
- command: step.command,
52348
- commandType: step.commandType
52349
- })),
52350
- requiredWorkflow: "plan-approval"
52351
- }
52858
+ if (!skip("plan_suggest"))
52859
+ server.registerTool(
52860
+ "plan_suggest",
52861
+ {
52862
+ title: "Draft a SwitchBot execution plan from intent",
52863
+ description: "Generate a candidate Plan JSON from a natural language intent and a list of device IDs. Uses keyword heuristics (no LLM) to pick the command. The returned plan is ready to pass to `plan run` \u2014 review and edit before executing. Recognised commands: turnOn, turnOff, press, lock, unlock, open, close, pause. Falls back to turnOn with a warning when intent is unclear.",
52864
+ _meta: { agentSafetyTier: "read" },
52865
+ inputSchema: external_exports.object({
52866
+ intent: external_exports.string().min(1).describe('Natural language description of what to do (e.g. "turn off all lights").'),
52867
+ device_ids: external_exports.array(external_exports.string().min(1)).min(1).describe("Device IDs to act on.")
52868
+ }).strict(),
52869
+ outputSchema: {
52870
+ plan: external_exports.unknown().describe("Candidate Plan JSON (version 1.0) ready to pass to plan run."),
52871
+ warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted to turnOn).")
52872
+ }
52873
+ },
52874
+ ({ intent, device_ids }) => {
52875
+ const devices = device_ids.map((id) => {
52876
+ const cached2 = getCachedDevice(id);
52877
+ return { id, name: cached2?.name, type: cached2?.type };
52352
52878
  });
52879
+ try {
52880
+ const { plan, warnings } = suggestPlan({ intent, devices });
52881
+ return {
52882
+ content: [{ type: "text", text: JSON.stringify({ plan, warnings }, null, 2) }],
52883
+ structuredContent: { plan, warnings }
52884
+ };
52885
+ } catch (err) {
52886
+ return apiErrorToMcpError(err);
52887
+ }
52353
52888
  }
52354
- for (let i = 0; i < validated.plan.steps.length; i++) {
52355
- const step = validated.plan.steps[i];
52356
- const idx = i + 1;
52357
- if (step.type === "wait") {
52358
- await new Promise((resolve2) => setTimeout(resolve2, step.ms));
52359
- out.results.push({ step: idx, type: "wait", ms: step.ms, status: "ok" });
52360
- out.summary.ok++;
52361
- continue;
52889
+ );
52890
+ if (!skip("plan_run"))
52891
+ server.registerTool(
52892
+ "plan_run",
52893
+ {
52894
+ title: "Validate and execute a SwitchBot plan",
52895
+ description: "Execute a Plan JSON object (version 1.0). Destructive command steps are skipped unless yes=true, and the default safety profile still refuses direct destructive execution in favor of the reviewed plan workflow. Scene and wait steps run in order. Returns per-step results and a summary.",
52896
+ _meta: { agentSafetyTier: "action" },
52897
+ inputSchema: external_exports.object({
52898
+ plan: external_exports.unknown().describe("Plan JSON object (same schema as `switchbot plan run`)."),
52899
+ yes: external_exports.boolean().optional().describe("Authorize destructive command steps."),
52900
+ continue_on_error: external_exports.boolean().optional().describe("Keep executing later steps after a failed step.")
52901
+ }).strict(),
52902
+ outputSchema: {
52903
+ ran: external_exports.boolean(),
52904
+ plan: external_exports.unknown(),
52905
+ results: external_exports.array(external_exports.unknown()),
52906
+ summary: external_exports.object({
52907
+ total: external_exports.number().int(),
52908
+ ok: external_exports.number().int(),
52909
+ error: external_exports.number().int(),
52910
+ skipped: external_exports.number().int()
52911
+ })
52362
52912
  }
52363
- if (step.type === "scene") {
52364
- try {
52365
- await executeScene(step.sceneId);
52366
- out.results.push({ step: idx, type: "scene", sceneId: step.sceneId, status: "ok" });
52367
- out.summary.ok++;
52368
- } catch (err) {
52369
- const msg = err instanceof Error ? err.message : String(err);
52370
- out.results.push({ step: idx, type: "scene", sceneId: step.sceneId, status: "error", error: msg });
52371
- out.summary.error++;
52372
- if (!continueOnError) break;
52373
- }
52374
- continue;
52913
+ },
52914
+ async ({ plan, yes, continue_on_error }) => {
52915
+ const validated = validatePlan(plan);
52916
+ if (!validated.ok) {
52917
+ return mcpError("usage", 2, "plan invalid", {
52918
+ context: { issues: validated.issues },
52919
+ hint: "Fix the reported issues and retry plan_run."
52920
+ });
52375
52921
  }
52376
- let resolvedDeviceId = "";
52377
- try {
52378
- resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
52922
+ const out = {
52923
+ ran: true,
52924
+ plan: validated.plan,
52925
+ results: [],
52926
+ summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 }
52927
+ };
52928
+ const continueOnError = continue_on_error === true;
52929
+ const allowDestructive = yes === true;
52930
+ const destructiveSteps = validated.plan.steps.map((step, index) => ({ step, index })).filter((entry) => entry.step.type === "command").map(({ step, index }) => {
52931
+ const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
52379
52932
  const commandType = step.commandType ?? "command";
52380
52933
  const deviceType = getCachedDevice(resolvedDeviceId)?.type;
52381
- const destructive = isDestructiveCommand(deviceType, step.command, commandType);
52382
- if (destructive && !allowDestructive) {
52934
+ return {
52935
+ index: index + 1,
52936
+ deviceId: resolvedDeviceId,
52937
+ command: step.command,
52938
+ commandType,
52939
+ deviceType: deviceType ?? null,
52940
+ destructive: isDestructiveCommand(deviceType, step.command, commandType)
52941
+ };
52942
+ }).filter((step) => step.destructive);
52943
+ if (allowDestructive && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
52944
+ return mcpError("guard", 3, "Direct destructive execution is disabled for plan_run.", {
52945
+ hint: destructiveExecutionHint(),
52946
+ context: {
52947
+ destructiveSteps: destructiveSteps.map((step) => ({
52948
+ step: step.index,
52949
+ deviceId: step.deviceId,
52950
+ deviceType: step.deviceType,
52951
+ command: step.command,
52952
+ commandType: step.commandType
52953
+ })),
52954
+ requiredWorkflow: "plan-approval"
52955
+ }
52956
+ });
52957
+ }
52958
+ for (let i = 0; i < validated.plan.steps.length; i++) {
52959
+ const step = validated.plan.steps[i];
52960
+ const idx = i + 1;
52961
+ if (step.type === "wait") {
52962
+ await new Promise((resolve2) => setTimeout(resolve2, step.ms));
52963
+ out.results.push({ step: idx, type: "wait", ms: step.ms, status: "ok" });
52964
+ out.summary.ok++;
52965
+ continue;
52966
+ }
52967
+ if (step.type === "scene") {
52968
+ try {
52969
+ await executeScene(step.sceneId);
52970
+ out.results.push({ step: idx, type: "scene", sceneId: step.sceneId, status: "ok" });
52971
+ out.summary.ok++;
52972
+ } catch (err) {
52973
+ const msg = err instanceof Error ? err.message : String(err);
52974
+ out.results.push({ step: idx, type: "scene", sceneId: step.sceneId, status: "error", error: msg });
52975
+ out.summary.error++;
52976
+ if (!continueOnError) break;
52977
+ }
52978
+ continue;
52979
+ }
52980
+ let resolvedDeviceId = "";
52981
+ try {
52982
+ resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
52983
+ const commandType = step.commandType ?? "command";
52984
+ const deviceType = getCachedDevice(resolvedDeviceId)?.type;
52985
+ const destructive = isDestructiveCommand(deviceType, step.command, commandType);
52986
+ if (destructive && !allowDestructive) {
52987
+ out.results.push({
52988
+ step: idx,
52989
+ type: "command",
52990
+ deviceId: resolvedDeviceId,
52991
+ command: step.command,
52992
+ status: "skipped",
52993
+ error: "destructive \u2014 rerun with yes=true"
52994
+ });
52995
+ out.summary.skipped++;
52996
+ if (!continueOnError) break;
52997
+ continue;
52998
+ }
52999
+ await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
52383
53000
  out.results.push({
52384
53001
  step: idx,
52385
53002
  type: "command",
52386
53003
  deviceId: resolvedDeviceId,
52387
53004
  command: step.command,
52388
- status: "skipped",
52389
- error: "destructive \u2014 rerun with yes=true"
53005
+ status: "ok"
52390
53006
  });
52391
- out.summary.skipped++;
52392
- if (!continueOnError) break;
52393
- continue;
52394
- }
52395
- await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
52396
- out.results.push({
52397
- step: idx,
52398
- type: "command",
52399
- deviceId: resolvedDeviceId,
52400
- command: step.command,
52401
- status: "ok"
52402
- });
52403
- out.summary.ok++;
52404
- } catch (err) {
52405
- if (err instanceof Error && err.name === "DryRunSignal") {
53007
+ out.summary.ok++;
53008
+ } catch (err) {
53009
+ if (err instanceof Error && err.name === "DryRunSignal") {
53010
+ out.results.push({
53011
+ step: idx,
53012
+ type: "command",
53013
+ deviceId: resolvedDeviceId || step.deviceId || "unknown",
53014
+ command: step.command,
53015
+ status: "ok"
53016
+ });
53017
+ out.summary.ok++;
53018
+ continue;
53019
+ }
53020
+ const msg = err instanceof Error ? err.message : String(err);
52406
53021
  out.results.push({
52407
53022
  step: idx,
52408
53023
  type: "command",
52409
53024
  deviceId: resolvedDeviceId || step.deviceId || "unknown",
52410
53025
  command: step.command,
52411
- status: "ok"
53026
+ status: "error",
53027
+ error: msg
52412
53028
  });
52413
- out.summary.ok++;
52414
- continue;
53029
+ out.summary.error++;
53030
+ if (!continueOnError) break;
52415
53031
  }
52416
- const msg = err instanceof Error ? err.message : String(err);
52417
- out.results.push({
52418
- step: idx,
52419
- type: "command",
52420
- deviceId: resolvedDeviceId || step.deviceId || "unknown",
52421
- command: step.command,
52422
- status: "error",
52423
- error: msg
52424
- });
52425
- out.summary.error++;
52426
- if (!continueOnError) break;
52427
53032
  }
52428
- }
52429
- return {
52430
- content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
52431
- structuredContent: out
52432
- };
52433
- }
52434
- );
52435
- server.registerTool(
52436
- "audit_query",
52437
- {
52438
- title: "Query command/rule audit log entries",
52439
- description: "Filter entries from the local audit log (default ~/.switchbot/audit.log) by time range, kind, device, rule, and result. Useful for review flows and rule-fire inspection without leaving MCP.",
52440
- _meta: { agentSafetyTier: "read" },
52441
- inputSchema: external_exports.object({
52442
- file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
52443
- since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
52444
- from: external_exports.string().optional().describe("Range start (ISO-8601)."),
52445
- to: external_exports.string().optional().describe("Range end (ISO-8601)."),
52446
- kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
52447
- device_id: external_exports.string().optional().describe("Filter by deviceId."),
52448
- rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
52449
- results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
52450
- limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries returned from the tail of the filtered set (default 200).")
52451
- }).strict(),
52452
- outputSchema: {
52453
- file: external_exports.string(),
52454
- totalMatched: external_exports.number().int(),
52455
- returned: external_exports.number().int(),
52456
- entries: external_exports.array(external_exports.unknown())
52457
- }
52458
- },
52459
- ({ file: file2, since, from, to, kinds, device_id, rule_name, results, limit }) => {
52460
- const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
52461
- const entries = readAudit(filePath);
52462
- try {
52463
- const filtered = filterAuditEntries(entries, {
52464
- since,
52465
- from,
52466
- to,
52467
- kinds,
52468
- deviceId: device_id,
52469
- ruleName: rule_name,
52470
- results
52471
- });
52472
- const bounded = filtered.slice(-Math.max(1, limit ?? 200));
52473
- const out = {
52474
- file: filePath,
52475
- totalMatched: filtered.length,
52476
- returned: bounded.length,
52477
- entries: bounded
52478
- };
52479
53033
  return {
52480
53034
  content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
52481
53035
  structuredContent: out
52482
53036
  };
52483
- } catch (err) {
52484
- return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit query options");
52485
53037
  }
52486
- }
52487
- );
52488
- server.registerTool(
52489
- "audit_stats",
52490
- {
52491
- title: "Aggregate audit log counts for review dashboards",
52492
- description: "Compute summary counters over the local audit log: by kind, by result, top devices, and top rules. Supports the same filters as audit_query.",
52493
- _meta: { agentSafetyTier: "read" },
52494
- inputSchema: external_exports.object({
52495
- file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
52496
- since: external_exports.string().optional().describe('Relative window ending now (e.g. "6h"). Mutually exclusive with from/to.'),
52497
- from: external_exports.string().optional().describe("Range start (ISO-8601)."),
52498
- to: external_exports.string().optional().describe("Range end (ISO-8601)."),
52499
- kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
52500
- device_id: external_exports.string().optional().describe("Filter by deviceId."),
52501
- rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
52502
- results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
52503
- top_n: external_exports.number().int().min(1).max(100).optional().describe("Number of top device/rule rows to return (default 10).")
52504
- }).strict(),
52505
- outputSchema: {
52506
- file: external_exports.string(),
52507
- totalMatched: external_exports.number().int(),
52508
- byKind: external_exports.record(external_exports.string(), external_exports.number().int()),
52509
- byResult: external_exports.record(external_exports.string(), external_exports.number().int()),
52510
- topDevices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), count: external_exports.number().int() })),
52511
- topRules: external_exports.array(external_exports.object({ ruleName: external_exports.string(), count: external_exports.number().int() }))
53038
+ );
53039
+ if (!skip("audit_query"))
53040
+ server.registerTool(
53041
+ "audit_query",
53042
+ {
53043
+ title: "Query command/rule audit log entries",
53044
+ description: "Filter entries from the local audit log (default ~/.switchbot/audit.log) by time range, kind, device, rule, and result. Useful for review flows and rule-fire inspection without leaving MCP.",
53045
+ _meta: { agentSafetyTier: "read" },
53046
+ inputSchema: external_exports.object({
53047
+ file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
53048
+ since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
53049
+ from: external_exports.string().optional().describe("Range start (ISO-8601)."),
53050
+ to: external_exports.string().optional().describe("Range end (ISO-8601)."),
53051
+ kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
53052
+ device_id: external_exports.string().optional().describe("Filter by deviceId."),
53053
+ rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
53054
+ results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
53055
+ limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries returned from the tail of the filtered set (default 200).")
53056
+ }).strict(),
53057
+ outputSchema: {
53058
+ file: external_exports.string(),
53059
+ totalMatched: external_exports.number().int(),
53060
+ returned: external_exports.number().int(),
53061
+ entries: external_exports.array(external_exports.unknown())
53062
+ }
53063
+ },
53064
+ ({ file: file2, since, from, to, kinds, device_id, rule_name, results, limit }) => {
53065
+ const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
53066
+ const entries = readAudit(filePath);
53067
+ try {
53068
+ const filtered = filterAuditEntries(entries, {
53069
+ since,
53070
+ from,
53071
+ to,
53072
+ kinds,
53073
+ deviceId: device_id,
53074
+ ruleName: rule_name,
53075
+ results
53076
+ });
53077
+ const bounded = filtered.slice(-Math.max(1, limit ?? 200));
53078
+ const out = {
53079
+ file: filePath,
53080
+ totalMatched: filtered.length,
53081
+ returned: bounded.length,
53082
+ entries: bounded
53083
+ };
53084
+ return {
53085
+ content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
53086
+ structuredContent: out
53087
+ };
53088
+ } catch (err) {
53089
+ return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit query options");
53090
+ }
52512
53091
  }
52513
- },
52514
- ({ file: file2, since, from, to, kinds, device_id, rule_name, results, top_n }) => {
52515
- const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
52516
- const entries = readAudit(filePath);
52517
- try {
52518
- const filtered = filterAuditEntries(entries, {
52519
- since,
52520
- from,
52521
- to,
52522
- kinds,
52523
- deviceId: device_id,
52524
- ruleName: rule_name,
52525
- results
52526
- });
52527
- const byKind = /* @__PURE__ */ new Map();
52528
- const byResult = /* @__PURE__ */ new Map();
52529
- const byDevice = /* @__PURE__ */ new Map();
52530
- const byRule = /* @__PURE__ */ new Map();
52531
- for (const entry of filtered) {
52532
- byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + 1);
52533
- if (entry.result) byResult.set(entry.result, (byResult.get(entry.result) ?? 0) + 1);
52534
- if (entry.deviceId) byDevice.set(entry.deviceId, (byDevice.get(entry.deviceId) ?? 0) + 1);
52535
- if (entry.rule?.name) byRule.set(entry.rule.name, (byRule.get(entry.rule.name) ?? 0) + 1);
52536
- }
52537
- const topN = top_n ?? 10;
52538
- const out = {
52539
- file: filePath,
52540
- totalMatched: filtered.length,
52541
- byKind: Object.fromEntries([...byKind.entries()].sort((a, b2) => a[0].localeCompare(b2[0]))),
52542
- byResult: Object.fromEntries([...byResult.entries()].sort((a, b2) => a[0].localeCompare(b2[0]))),
52543
- topDevices: topNFromMap(byDevice, topN).map((item) => ({ deviceId: item.key, count: item.count })),
52544
- topRules: topNFromMap(byRule, topN).map((item) => ({ ruleName: item.key, count: item.count }))
52545
- };
53092
+ );
53093
+ if (!skip("audit_stats"))
53094
+ server.registerTool(
53095
+ "audit_stats",
53096
+ {
53097
+ title: "Aggregate audit log counts for review dashboards",
53098
+ description: "Compute summary counters over the local audit log: by kind, by result, top devices, and top rules. Supports the same filters as audit_query.",
53099
+ _meta: { agentSafetyTier: "read" },
53100
+ inputSchema: external_exports.object({
53101
+ file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
53102
+ since: external_exports.string().optional().describe('Relative window ending now (e.g. "6h"). Mutually exclusive with from/to.'),
53103
+ from: external_exports.string().optional().describe("Range start (ISO-8601)."),
53104
+ to: external_exports.string().optional().describe("Range end (ISO-8601)."),
53105
+ kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
53106
+ device_id: external_exports.string().optional().describe("Filter by deviceId."),
53107
+ rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
53108
+ results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
53109
+ top_n: external_exports.number().int().min(1).max(100).optional().describe("Number of top device/rule rows to return (default 10).")
53110
+ }).strict(),
53111
+ outputSchema: {
53112
+ file: external_exports.string(),
53113
+ totalMatched: external_exports.number().int(),
53114
+ byKind: external_exports.record(external_exports.string(), external_exports.number().int()),
53115
+ byResult: external_exports.record(external_exports.string(), external_exports.number().int()),
53116
+ topDevices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), count: external_exports.number().int() })),
53117
+ topRules: external_exports.array(external_exports.object({ ruleName: external_exports.string(), count: external_exports.number().int() }))
53118
+ }
53119
+ },
53120
+ ({ file: file2, since, from, to, kinds, device_id, rule_name, results, top_n }) => {
53121
+ const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
53122
+ const entries = readAudit(filePath);
53123
+ try {
53124
+ const filtered = filterAuditEntries(entries, {
53125
+ since,
53126
+ from,
53127
+ to,
53128
+ kinds,
53129
+ deviceId: device_id,
53130
+ ruleName: rule_name,
53131
+ results
53132
+ });
53133
+ const byKind = /* @__PURE__ */ new Map();
53134
+ const byResult = /* @__PURE__ */ new Map();
53135
+ const byDevice = /* @__PURE__ */ new Map();
53136
+ const byRule = /* @__PURE__ */ new Map();
53137
+ for (const entry of filtered) {
53138
+ byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + 1);
53139
+ if (entry.result) byResult.set(entry.result, (byResult.get(entry.result) ?? 0) + 1);
53140
+ if (entry.deviceId) byDevice.set(entry.deviceId, (byDevice.get(entry.deviceId) ?? 0) + 1);
53141
+ if (entry.rule?.name) byRule.set(entry.rule.name, (byRule.get(entry.rule.name) ?? 0) + 1);
53142
+ }
53143
+ const topN = top_n ?? 10;
53144
+ const out = {
53145
+ file: filePath,
53146
+ totalMatched: filtered.length,
53147
+ byKind: Object.fromEntries([...byKind.entries()].sort((a, b2) => a[0].localeCompare(b2[0]))),
53148
+ byResult: Object.fromEntries([...byResult.entries()].sort((a, b2) => a[0].localeCompare(b2[0]))),
53149
+ topDevices: topNFromMap(byDevice, topN).map((item) => ({ deviceId: item.key, count: item.count })),
53150
+ topRules: topNFromMap(byRule, topN).map((item) => ({ ruleName: item.key, count: item.count }))
53151
+ };
53152
+ return {
53153
+ content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
53154
+ structuredContent: out
53155
+ };
53156
+ } catch (err) {
53157
+ return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit stats options");
53158
+ }
53159
+ }
53160
+ );
53161
+ if (!skip("rule_notifications"))
53162
+ server.registerTool(
53163
+ "rule_notifications",
53164
+ {
53165
+ title: "Query rule notification delivery history",
53166
+ description: "Returns audit entries for notify actions (kind: rule-notify). Useful for confirming whether a notification rule fired and whether delivery succeeded. Filter by rule name, time range, result, or channel.",
53167
+ _meta: { agentSafetyTier: "read" },
53168
+ inputSchema: external_exports.object({
53169
+ file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
53170
+ rule: external_exports.string().optional().describe("Filter by rule name (exact match)."),
53171
+ since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h").'),
53172
+ from: external_exports.string().optional().describe("Range start (ISO-8601)."),
53173
+ to: external_exports.string().optional().describe("Range end (ISO-8601)."),
53174
+ result: external_exports.enum(["ok", "error"]).optional().describe("Filter by delivery result."),
53175
+ channel: external_exports.enum(["webhook", "openclaw", "file"]).optional().describe("Filter by notify channel."),
53176
+ limit: external_exports.number().int().min(1).max(500).default(100).describe("Max entries to return (newest first).")
53177
+ }).strict(),
53178
+ outputSchema: {
53179
+ entries: external_exports.array(external_exports.unknown()).describe("Matching audit entries, newest first."),
53180
+ total: external_exports.number().int().describe("Count after filtering.")
53181
+ }
53182
+ },
53183
+ ({ file: file2, rule: ruleName, since, from, to, result: resultFilter, channel, limit }) => {
53184
+ const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
53185
+ let entries = readAudit(filePath).filter((e) => e.kind === "rule-notify");
53186
+ if (ruleName) entries = entries.filter((e) => e.rule?.name === ruleName);
53187
+ if (resultFilter) entries = entries.filter((e) => e.result === resultFilter);
53188
+ if (channel) entries = entries.filter((e) => e.notifyChannel === channel);
53189
+ try {
53190
+ entries = filterAuditEntries(entries, { since, from, to });
53191
+ } catch (err) {
53192
+ return mcpError("usage", 2, err instanceof Error ? err.message : "invalid filter options");
53193
+ }
53194
+ const bounded = entries.slice(-limit).reverse();
53195
+ const out = { entries: bounded, total: entries.length };
52546
53196
  return {
52547
53197
  content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
52548
53198
  structuredContent: out
52549
53199
  };
52550
- } catch (err) {
52551
- return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit stats options");
52552
- }
52553
- }
52554
- );
52555
- server.registerTool(
52556
- "rule_notifications",
52557
- {
52558
- title: "Query rule notification delivery history",
52559
- description: "Returns audit entries for notify actions (kind: rule-notify). Useful for confirming whether a notification rule fired and whether delivery succeeded. Filter by rule name, time range, result, or channel.",
52560
- _meta: { agentSafetyTier: "read" },
52561
- inputSchema: external_exports.object({
52562
- file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
52563
- rule: external_exports.string().optional().describe("Filter by rule name (exact match)."),
52564
- since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h").'),
52565
- from: external_exports.string().optional().describe("Range start (ISO-8601)."),
52566
- to: external_exports.string().optional().describe("Range end (ISO-8601)."),
52567
- result: external_exports.enum(["ok", "error"]).optional().describe("Filter by delivery result."),
52568
- channel: external_exports.enum(["webhook", "openclaw", "file"]).optional().describe("Filter by notify channel."),
52569
- limit: external_exports.number().int().min(1).max(500).default(100).describe("Max entries to return (newest first).")
52570
- }).strict(),
52571
- outputSchema: {
52572
- entries: external_exports.array(external_exports.unknown()).describe("Matching audit entries, newest first."),
52573
- total: external_exports.number().int().describe("Count after filtering.")
52574
- }
52575
- },
52576
- ({ file: file2, rule: ruleName, since, from, to, result: resultFilter, channel, limit }) => {
52577
- const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
52578
- let entries = readAudit(filePath).filter((e) => e.kind === "rule-notify");
52579
- if (ruleName) entries = entries.filter((e) => e.rule?.name === ruleName);
52580
- if (resultFilter) entries = entries.filter((e) => e.result === resultFilter);
52581
- if (channel) entries = entries.filter((e) => e.notifyChannel === channel);
52582
- try {
52583
- entries = filterAuditEntries(entries, { since, from, to });
52584
- } catch (err) {
52585
- return mcpError("usage", 2, err instanceof Error ? err.message : "invalid filter options");
52586
- }
52587
- const bounded = entries.slice(-limit).reverse();
52588
- const out = { entries: bounded, total: entries.length };
52589
- return {
52590
- content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
52591
- structuredContent: out
52592
- };
52593
- }
52594
- );
52595
- server.registerTool(
52596
- "rules_suggest",
52597
- {
52598
- title: "Draft a SwitchBot automation rule from intent",
52599
- description: "Generate a candidate automation rule YAML from a natural language intent. Uses keyword heuristics by default; pass llm to use an LLM backend (auto | openai | anthropic). Always emits dry_run: true \u2014 the rule must be reviewed before arming. Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.",
52600
- _meta: { agentSafetyTier: "read" },
52601
- inputSchema: external_exports.object({
52602
- intent: external_exports.string().min(1).describe('Natural language description (e.g. "turn off lights at 10pm").'),
52603
- trigger: external_exports.enum(["mqtt", "cron", "webhook"]).optional().describe("Trigger type (inferred from intent if omitted)."),
52604
- device_ids: external_exports.array(external_exports.string().min(1)).optional().describe("Device IDs; first is sensor for mqtt triggers, rest are action targets."),
52605
- event: external_exports.string().optional().describe("MQTT event name override (e.g. motion.detected)."),
52606
- schedule: external_exports.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
52607
- days: external_exports.array(external_exports.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
52608
- webhook_path: external_exports.string().optional().describe("Webhook path override (default /action)."),
52609
- llm: external_exports.enum(["auto", "openai", "anthropic"]).optional().describe("LLM backend (auto | openai | anthropic). Omit to use keyword heuristics.")
52610
- }).strict(),
52611
- outputSchema: {
52612
- rule: external_exports.unknown().describe("Rule object matching the v0.2 policy schema."),
52613
- rule_yaml: external_exports.string().describe("YAML string ready to pipe to policy_add_rule."),
52614
- warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted).")
52615
53200
  }
52616
- },
52617
- async ({ intent, trigger, device_ids, event, schedule, days, webhook_path, llm }) => {
52618
- const devices = (device_ids ?? []).map((id) => {
52619
- const cached2 = getCachedDevice(id);
52620
- return { id, name: cached2?.name, type: cached2?.type };
52621
- });
52622
- try {
52623
- const { rule, ruleYaml, warnings } = await suggestRule({
52624
- intent,
52625
- trigger,
52626
- devices,
52627
- event,
52628
- schedule,
52629
- days,
52630
- webhookPath: webhook_path,
52631
- llm
53201
+ );
53202
+ if (!skip("rules_suggest"))
53203
+ server.registerTool(
53204
+ "rules_suggest",
53205
+ {
53206
+ title: "Draft a SwitchBot automation rule from intent",
53207
+ description: "Generate a candidate automation rule YAML from a natural language intent. Uses keyword heuristics by default; pass llm to use an LLM backend (auto | openai | anthropic). Always emits dry_run: true \u2014 the rule must be reviewed before arming. Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.",
53208
+ _meta: { agentSafetyTier: "read" },
53209
+ inputSchema: external_exports.object({
53210
+ intent: external_exports.string().min(1).describe('Natural language description (e.g. "turn off lights at 10pm").'),
53211
+ trigger: external_exports.enum(["mqtt", "cron", "webhook"]).optional().describe("Trigger type (inferred from intent if omitted)."),
53212
+ device_ids: external_exports.array(external_exports.string().min(1)).optional().describe("Device IDs; first is sensor for mqtt triggers, rest are action targets."),
53213
+ event: external_exports.string().optional().describe("MQTT event name override (e.g. motion.detected)."),
53214
+ schedule: external_exports.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
53215
+ days: external_exports.array(external_exports.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
53216
+ webhook_path: external_exports.string().optional().describe("Webhook path override (default /action)."),
53217
+ llm: external_exports.enum(["auto", "openai", "anthropic"]).optional().describe("LLM backend (auto | openai | anthropic). Omit to use keyword heuristics.")
53218
+ }).strict(),
53219
+ outputSchema: {
53220
+ rule: external_exports.unknown().describe("Rule object matching the v0.2 policy schema."),
53221
+ rule_yaml: external_exports.string().describe("YAML string ready to pipe to policy_add_rule."),
53222
+ warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted).")
53223
+ }
53224
+ },
53225
+ async ({ intent, trigger, device_ids, event, schedule, days, webhook_path, llm }) => {
53226
+ const devices = (device_ids ?? []).map((id) => {
53227
+ const cached2 = getCachedDevice(id);
53228
+ return { id, name: cached2?.name, type: cached2?.type };
52632
53229
  });
52633
- return {
52634
- content: [{ type: "text", text: ruleYaml }],
52635
- structuredContent: { rule, rule_yaml: ruleYaml, warnings }
52636
- };
52637
- } catch (err) {
52638
- return apiErrorToMcpError(err);
52639
- }
52640
- }
52641
- );
52642
- server.registerTool(
52643
- "rules_explain",
52644
- {
52645
- title: "Show why a rule evaluation fired or was blocked",
52646
- description: 'Read rule-evaluate trace records from the audit log and format them for inspection. Pass fire_id to explain a specific evaluation; or pass rule_name with last:true for the most recent evaluation; or pass rule_name + since for a window. Returns trace records only when automation.audit.evaluate_trace is "sampled" or "full".',
52647
- _meta: { agentSafetyTier: "read" },
52648
- inputSchema: external_exports.object({
52649
- fire_id: external_exports.string().optional().describe("Specific fireId to explain."),
52650
- rule_name: external_exports.string().optional().describe("Filter to this rule name."),
52651
- since: external_exports.string().optional().describe("Duration string (e.g. 1h, 7d) \u2014 show evaluations in this window."),
52652
- last: external_exports.boolean().optional().describe("Return only the most recent evaluation (requires rule_name)."),
52653
- audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
52654
- }).strict(),
52655
- outputSchema: {
52656
- records: external_exports.array(external_exports.unknown()).describe("Array of trace + relatedAudit objects."),
52657
- count: external_exports.number().describe("Number of trace records returned.")
52658
- }
52659
- },
52660
- async ({ fire_id, rule_name, since, last, audit_log }) => {
52661
- const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
52662
- const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
52663
- const sinceIso = since ? new Date(Date.now() - (parseDurationToMs(since) ?? 0)).toISOString() : void 0;
52664
- let records = loadTraceRecords(auditFile, {
52665
- fireId: fire_id,
52666
- ruleName: rule_name,
52667
- since: sinceIso
52668
- });
52669
- if (records.length === 0) {
52670
- return {
52671
- content: [{ type: "text", text: 'No rule-evaluate trace records found. Check that automation.audit.evaluate_trace is "sampled" or "full".' }],
52672
- structuredContent: { records: [], count: 0 }
52673
- };
52674
- }
52675
- if (last) {
52676
- records = [records[records.length - 1]];
52677
- }
52678
- const output = records.map((record2) => {
52679
- const related = loadRelatedAudit(auditFile, record2.fireId);
52680
- return JSON.parse(formatExplainJson(record2, related));
52681
- });
52682
- return {
52683
- content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
52684
- structuredContent: { records: output, count: output.length }
52685
- };
52686
- }
52687
- );
52688
- server.registerTool(
52689
- "rules_simulate",
52690
- {
52691
- title: "Simulate a rule against historical events",
52692
- description: "Replay historical events from the audit log or a JSONL file against a rule definition and report would-fire / blocked-by-condition / throttled outcomes. Useful for validating a new or modified rule before deployment. Pass rule_yaml to test an unpublished rule, or rule_name + policy_path to test a deployed rule.",
52693
- _meta: { agentSafetyTier: "read" },
52694
- inputSchema: external_exports.object({
52695
- rule_yaml: external_exports.string().optional().describe("Standalone rule YAML (takes precedence over policy_path + rule_name)."),
52696
- policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to ~/.switchbot/policy.yaml)."),
52697
- rule_name: external_exports.string().optional().describe("Name of the rule in policy.yaml to simulate."),
52698
- since: external_exports.string().optional().describe("Replay events from this window (e.g. 7d, 24h)."),
52699
- against: external_exports.string().optional().describe("JSONL file path of EngineEvent objects to replay."),
52700
- live_llm: external_exports.boolean().optional().describe("Allow live LLM calls for llm conditions (default: skip and report as would-call)."),
52701
- audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
52702
- }).strict(),
52703
- outputSchema: {
52704
- report: external_exports.unknown().describe("SimulateReport object.")
52705
- }
52706
- },
52707
- async ({ rule_yaml, policy_path, rule_name, since, against, live_llm, audit_log }) => {
52708
- const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
52709
- const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
52710
- let rule;
52711
- if (rule_yaml) {
52712
53230
  try {
52713
- rule = (0, import_yaml7.parse)(rule_yaml);
53231
+ const { rule, ruleYaml, warnings } = await suggestRule({
53232
+ intent,
53233
+ trigger,
53234
+ devices,
53235
+ event,
53236
+ schedule,
53237
+ days,
53238
+ webhookPath: webhook_path,
53239
+ llm
53240
+ });
53241
+ return {
53242
+ content: [{ type: "text", text: ruleYaml }],
53243
+ structuredContent: { rule, rule_yaml: ruleYaml, warnings }
53244
+ };
52714
53245
  } catch (err) {
53246
+ return apiErrorToMcpError(err);
53247
+ }
53248
+ }
53249
+ );
53250
+ if (!skip("rules_explain"))
53251
+ server.registerTool(
53252
+ "rules_explain",
53253
+ {
53254
+ title: "Show why a rule evaluation fired or was blocked",
53255
+ description: 'Read rule-evaluate trace records from the audit log and format them for inspection. Pass fire_id to explain a specific evaluation; or pass rule_name with last:true for the most recent evaluation; or pass rule_name + since for a window. Returns trace records only when automation.audit.evaluate_trace is "sampled" or "full".',
53256
+ _meta: { agentSafetyTier: "read" },
53257
+ inputSchema: external_exports.object({
53258
+ fire_id: external_exports.string().optional().describe("Specific fireId to explain."),
53259
+ rule_name: external_exports.string().optional().describe("Filter to this rule name."),
53260
+ since: external_exports.string().optional().describe("Duration string (e.g. 1h, 7d) \u2014 show evaluations in this window."),
53261
+ last: external_exports.boolean().optional().describe("Return only the most recent evaluation (requires rule_name)."),
53262
+ audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
53263
+ }).strict(),
53264
+ outputSchema: {
53265
+ records: external_exports.array(external_exports.unknown()).describe("Array of trace + relatedAudit objects."),
53266
+ count: external_exports.number().describe("Number of trace records returned.")
53267
+ }
53268
+ },
53269
+ async ({ fire_id, rule_name, since, last, audit_log }) => {
53270
+ const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
53271
+ const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
53272
+ const sinceIso = since ? new Date(Date.now() - (parseDurationToMs(since) ?? 0)).toISOString() : void 0;
53273
+ let records = loadTraceRecords(auditFile, {
53274
+ fireId: fire_id,
53275
+ ruleName: rule_name,
53276
+ since: sinceIso
53277
+ });
53278
+ if (records.length === 0) {
52715
53279
  return {
52716
- content: [{ type: "text", text: `Failed to parse rule_yaml: ${String(err)}` }],
52717
- structuredContent: { report: null }
53280
+ content: [{ type: "text", text: 'No rule-evaluate trace records found. Check that automation.audit.evaluate_trace is "sampled" or "full".' }],
53281
+ structuredContent: { records: [], count: 0 }
52718
53282
  };
52719
53283
  }
52720
- } else if (policy_path || rule_name) {
52721
- const { loadPolicyFile: loadPolicyFile2 } = await Promise.resolve().then(() => (init_load(), load_exports));
52722
- const policyFile = policy_path ?? pathJoin(os14.homedir(), ".switchbot", "policy.yaml");
52723
- try {
52724
- const policy = loadPolicyFile2(policyFile);
52725
- const data = policy.data ?? {};
52726
- const found = data.automation?.rules?.find((r) => r.name === rule_name);
52727
- if (!found) {
53284
+ if (last) {
53285
+ records = [records[records.length - 1]];
53286
+ }
53287
+ const output = records.map((record2) => {
53288
+ const related = loadRelatedAudit(auditFile, record2.fireId);
53289
+ return JSON.parse(formatExplainJson(record2, related));
53290
+ });
53291
+ return {
53292
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
53293
+ structuredContent: { records: output, count: output.length }
53294
+ };
53295
+ }
53296
+ );
53297
+ if (!skip("rules_simulate"))
53298
+ server.registerTool(
53299
+ "rules_simulate",
53300
+ {
53301
+ title: "Simulate a rule against historical events",
53302
+ description: "Replay historical events from the audit log or a JSONL file against a rule definition and report would-fire / blocked-by-condition / throttled outcomes. Useful for validating a new or modified rule before deployment. Pass rule_yaml to test an unpublished rule, or rule_name + policy_path to test a deployed rule.",
53303
+ _meta: { agentSafetyTier: "read" },
53304
+ inputSchema: external_exports.object({
53305
+ rule_yaml: external_exports.string().optional().describe("Standalone rule YAML (takes precedence over policy_path + rule_name)."),
53306
+ policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to ~/.switchbot/policy.yaml)."),
53307
+ rule_name: external_exports.string().optional().describe("Name of the rule in policy.yaml to simulate."),
53308
+ since: external_exports.string().optional().describe("Replay events from this window (e.g. 7d, 24h)."),
53309
+ against: external_exports.string().optional().describe("JSONL file path of EngineEvent objects to replay."),
53310
+ live_llm: external_exports.boolean().optional().describe("Allow live LLM calls for llm conditions (default: skip and report as would-call)."),
53311
+ audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
53312
+ }).strict(),
53313
+ outputSchema: {
53314
+ report: external_exports.unknown().describe("SimulateReport object.")
53315
+ }
53316
+ },
53317
+ async ({ rule_yaml, policy_path, rule_name, since, against, live_llm, audit_log }) => {
53318
+ const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
53319
+ const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
53320
+ let rule;
53321
+ if (rule_yaml) {
53322
+ try {
53323
+ rule = (0, import_yaml7.parse)(rule_yaml);
53324
+ } catch (err) {
52728
53325
  return {
52729
- content: [{ type: "text", text: `Rule "${rule_name}" not found in ${policyFile}.` }],
53326
+ content: [{ type: "text", text: `Failed to parse rule_yaml: ${String(err)}` }],
52730
53327
  structuredContent: { report: null }
52731
53328
  };
52732
53329
  }
52733
- rule = found;
53330
+ } else if (policy_path || rule_name) {
53331
+ const { loadPolicyFile: loadPolicyFile2 } = await Promise.resolve().then(() => (init_load(), load_exports));
53332
+ const policyFile = policy_path ?? pathJoin(os14.homedir(), ".switchbot", "policy.yaml");
53333
+ try {
53334
+ const policy = loadPolicyFile2(policyFile);
53335
+ const data = policy.data ?? {};
53336
+ const found = data.automation?.rules?.find((r) => r.name === rule_name);
53337
+ if (!found) {
53338
+ return {
53339
+ content: [{ type: "text", text: `Rule "${rule_name}" not found in ${policyFile}.` }],
53340
+ structuredContent: { report: null }
53341
+ };
53342
+ }
53343
+ rule = found;
53344
+ } catch (err) {
53345
+ return {
53346
+ content: [{ type: "text", text: `Failed to load policy: ${String(err)}` }],
53347
+ structuredContent: { report: null }
53348
+ };
53349
+ }
53350
+ } else {
53351
+ return {
53352
+ content: [{ type: "text", text: "Provide rule_yaml or (policy_path + rule_name) to specify the rule to simulate." }],
53353
+ structuredContent: { report: null }
53354
+ };
53355
+ }
53356
+ try {
53357
+ const report = await simulateRule({
53358
+ rule,
53359
+ since,
53360
+ against,
53361
+ auditLog: auditFile,
53362
+ liveLlm: live_llm ?? false
53363
+ });
53364
+ return {
53365
+ content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
53366
+ structuredContent: { report }
53367
+ };
52734
53368
  } catch (err) {
52735
53369
  return {
52736
- content: [{ type: "text", text: `Failed to load policy: ${String(err)}` }],
53370
+ content: [{ type: "text", text: `Simulate error: ${String(err)}` }],
52737
53371
  structuredContent: { report: null }
52738
53372
  };
52739
53373
  }
52740
- } else {
52741
- return {
52742
- content: [{ type: "text", text: "Provide rule_yaml or (policy_path + rule_name) to specify the rule to simulate." }],
52743
- structuredContent: { report: null }
52744
- };
52745
53374
  }
52746
- try {
52747
- const report = await simulateRule({
52748
- rule,
52749
- since,
52750
- against,
52751
- auditLog: auditFile,
52752
- liveLlm: live_llm ?? false
52753
- });
52754
- return {
52755
- content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
52756
- structuredContent: { report }
52757
- };
52758
- } catch (err) {
52759
- return {
52760
- content: [{ type: "text", text: `Simulate error: ${String(err)}` }],
52761
- structuredContent: { report: null }
52762
- };
52763
- }
52764
- }
52765
- );
52766
- server.registerTool(
52767
- "policy_add_rule",
52768
- {
52769
- title: "Append a rule to automation.rules[] in policy.yaml",
52770
- description: "Inject a rule YAML snippet (as produced by rules_suggest) into the automation.rules[] array in policy.yaml. Preserves existing comments and formatting. Always run with dry_run: true first so the agent can show the diff for user approval. Never set enable_automation: true without explicitly informing the user.",
52771
- _meta: { agentSafetyTier: "action" },
52772
- inputSchema: external_exports.object({
52773
- rule_yaml: external_exports.string().min(1).describe("YAML string of a single rule object (e.g. from rules_suggest)."),
52774
- policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to $SWITCHBOT_POLICY_PATH or ~/.switchbot/policy.yaml)."),
52775
- enable_automation: external_exports.boolean().default(false).describe("If true, sets automation.enabled: true after inserting the rule."),
52776
- dry_run: external_exports.boolean().default(false).describe("If true, compute and return the diff without writing to disk."),
52777
- force: external_exports.boolean().default(false).describe("If true, overwrite an existing rule with the same name.")
52778
- }).strict(),
52779
- outputSchema: {
52780
- policyPath: external_exports.string().describe("Resolved path to the policy file."),
52781
- ruleName: external_exports.string().describe("Name of the rule that was (or would be) inserted."),
52782
- written: external_exports.boolean().describe("True when the file was actually written."),
52783
- diff: external_exports.string().describe("Unified-style diff showing lines added/removed.")
52784
- }
52785
- },
52786
- ({ rule_yaml, policy_path, enable_automation, dry_run, force }) => {
52787
- const policyPath = resolvePolicyPath({ flag: policy_path });
52788
- try {
52789
- const result = addRuleToPolicyFile({
52790
- ruleYaml: rule_yaml,
52791
- policyPath,
52792
- enableAutomation: enable_automation,
52793
- dryRun: dry_run,
52794
- force
52795
- });
52796
- const out = { policyPath, ruleName: result.ruleName, written: result.written, diff: result.diff };
52797
- return {
52798
- content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
52799
- structuredContent: out
52800
- };
52801
- } catch (err) {
52802
- if (err instanceof AddRuleError) {
52803
- return apiErrorToMcpError(new Error(`${err.code}: ${err.message}`));
53375
+ );
53376
+ if (!skip("policy_add_rule"))
53377
+ server.registerTool(
53378
+ "policy_add_rule",
53379
+ {
53380
+ title: "Append a rule to automation.rules[] in policy.yaml",
53381
+ description: "Inject a rule YAML snippet (as produced by rules_suggest) into the automation.rules[] array in policy.yaml. Preserves existing comments and formatting. Always run with dry_run: true first so the agent can show the diff for user approval. Never set enable_automation: true without explicitly informing the user.",
53382
+ _meta: { agentSafetyTier: "action" },
53383
+ inputSchema: external_exports.object({
53384
+ rule_yaml: external_exports.string().min(1).describe("YAML string of a single rule object (e.g. from rules_suggest)."),
53385
+ policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to $SWITCHBOT_POLICY_PATH or ~/.switchbot/policy.yaml)."),
53386
+ enable_automation: external_exports.boolean().default(false).describe("If true, sets automation.enabled: true after inserting the rule."),
53387
+ dry_run: external_exports.boolean().default(false).describe("If true, compute and return the diff without writing to disk."),
53388
+ force: external_exports.boolean().default(false).describe("If true, overwrite an existing rule with the same name.")
53389
+ }).strict(),
53390
+ outputSchema: {
53391
+ policyPath: external_exports.string().describe("Resolved path to the policy file."),
53392
+ ruleName: external_exports.string().describe("Name of the rule that was (or would be) inserted."),
53393
+ written: external_exports.boolean().describe("True when the file was actually written."),
53394
+ diff: external_exports.string().describe("Unified-style diff showing lines added/removed.")
53395
+ }
53396
+ },
53397
+ ({ rule_yaml, policy_path, enable_automation, dry_run, force }) => {
53398
+ const policyPath = resolvePolicyPath({ flag: policy_path });
53399
+ try {
53400
+ const result = addRuleToPolicyFile({
53401
+ ruleYaml: rule_yaml,
53402
+ policyPath,
53403
+ enableAutomation: enable_automation,
53404
+ dryRun: dry_run,
53405
+ force
53406
+ });
53407
+ const out = { policyPath, ruleName: result.ruleName, written: result.written, diff: result.diff };
53408
+ return {
53409
+ content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
53410
+ structuredContent: out
53411
+ };
53412
+ } catch (err) {
53413
+ if (err instanceof AddRuleError) {
53414
+ return apiErrorToMcpError(new Error(`${err.code}: ${err.message}`));
53415
+ }
53416
+ return apiErrorToMcpError(err);
52804
53417
  }
52805
- return apiErrorToMcpError(err);
52806
53418
  }
52807
- }
52808
- );
53419
+ );
52809
53420
  return server;
52810
53421
  }
52811
53422
  function listRegisteredTools(server) {
@@ -52831,8 +53442,8 @@ function listRegisteredToolsWithMeta(server) {
52831
53442
  function listRegisteredResources() {
52832
53443
  return ["switchbot://events"];
52833
53444
  }
52834
- function printMcpToolDirectory() {
52835
- const server = createSwitchBotMcpServer();
53445
+ function printMcpToolDirectory(toolProfile) {
53446
+ const server = createSwitchBotMcpServer({ toolProfile });
52836
53447
  const tools = listRegisteredToolsWithMeta(server);
52837
53448
  const resources = listRegisteredResources().map((uri) => ({ uri }));
52838
53449
  if (isJsonMode()) {
@@ -52904,16 +53515,30 @@ Example Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
52904
53515
  Inspect locally:
52905
53516
  $ npx @modelcontextprotocol/inspector switchbot mcp serve
52906
53517
  `);
52907
- mcp.command("tools").description("Print the registered MCP tools in human or JSON form").action(() => printMcpToolDirectory());
52908
- mcp.command("list-tools").description("Alias of `mcp tools`").action(() => printMcpToolDirectory());
52909
- mcp.command("serve").description("Start the MCP server on stdio (default) or HTTP (--port)").option("--port <n>", "Listen on HTTP instead of stdio (Streamable HTTP transport)", intArg("--port", { min: 1, max: 65535 })).option("--bind <host>", "IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)", stringArg("--bind"), "127.0.0.1").option("--auth-token <token>", "Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)", stringArg("--auth-token")).option("--cors-origin <url>", "Allowed CORS origin(s) for HTTP (repeatable)", stringArg("--cors-origin")).option("--rate-limit <n>", "Max requests per minute per profile (default 60)", intArg("--rate-limit", { min: 1 }), "60").addHelpText("after", `
53518
+ mcp.command("tools").description("Print the registered MCP tools in human or JSON form").option("--tools <profile>", "Tool profile: default, readonly, all (default: all)", stringArg("--tools"), "all").action((opts) => {
53519
+ try {
53520
+ printMcpToolDirectory(resolveToolProfile(opts.tools));
53521
+ } catch (e) {
53522
+ handleError(e);
53523
+ }
53524
+ });
53525
+ mcp.command("list-tools").description("Alias of `mcp tools`").option("--tools <profile>", "Tool profile: default, readonly, all (default: all)", stringArg("--tools"), "all").action((opts) => {
53526
+ try {
53527
+ printMcpToolDirectory(resolveToolProfile(opts.tools));
53528
+ } catch (e) {
53529
+ handleError(e);
53530
+ }
53531
+ });
53532
+ mcp.command("serve").description("Start the MCP server on stdio (default) or HTTP (--port)").option("--port <n>", "Listen on HTTP instead of stdio (Streamable HTTP transport)", intArg("--port", { min: 1, max: 65535 })).option("--bind <host>", "IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)", stringArg("--bind"), "127.0.0.1").option("--auth-token <token>", "Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)", stringArg("--auth-token")).option("--cors-origin <url>", "Allowed CORS origin(s) for HTTP (repeatable)", stringArg("--cors-origin")).option("--rate-limit <n>", "Max requests per minute per profile (default 60)", intArg("--rate-limit", { min: 1 }), "60").option("--tools <profile>", "Tool profile: default, readonly, all (default: default)", stringArg("--tools"), "default").addHelpText("after", `
52910
53533
  Examples:
52911
53534
  $ switchbot mcp serve
53535
+ $ switchbot mcp serve --tools all
52912
53536
  $ switchbot mcp serve --port 8787
52913
53537
  $ switchbot mcp serve --port 8787 --bind 127.0.0.1 --auth-token your-token
52914
53538
  $ switchbot mcp serve --port 8787 --bind 0.0.0.0 --auth-token your-token
52915
53539
  `).action(async (options) => {
52916
53540
  try {
53541
+ const toolProfile = resolveToolProfile(options.tools);
52917
53542
  if (options.port) {
52918
53543
  const port = Number(options.port);
52919
53544
  if (!Number.isFinite(port) || port < 1 || port > 65535) {
@@ -53062,7 +53687,7 @@ process_uptime_seconds ${Math.floor(process.uptime())}
53062
53687
  }
53063
53688
  }
53064
53689
  const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
53065
- const reqServer = createSwitchBotMcpServer({ eventManager: eventManager2 });
53690
+ const reqServer = createSwitchBotMcpServer({ eventManager: eventManager2, toolProfile });
53066
53691
  res.on("close", () => {
53067
53692
  reqTransport.close();
53068
53693
  reqServer.close();
@@ -53114,7 +53739,7 @@ process_uptime_seconds ${Math.floor(process.uptime())}
53114
53739
  console.error("MQTT initialization failed:", err instanceof Error ? err.message : String(err));
53115
53740
  });
53116
53741
  }
53117
- const server = createSwitchBotMcpServer({ eventManager });
53742
+ const server = createSwitchBotMcpServer({ eventManager, toolProfile });
53118
53743
  const transport = new StdioServerTransport();
53119
53744
  await server.connect(transport);
53120
53745
  let isShuttingDown = false;