@switchbot/openapi-cli 2.6.4 → 3.0.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 (67) hide show
  1. package/README.md +385 -103
  2. package/dist/api/client.js +13 -12
  3. package/dist/commands/agent-bootstrap.js +67 -16
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +26 -21
  6. package/dist/commands/capabilities.js +29 -21
  7. package/dist/commands/catalog.js +4 -3
  8. package/dist/commands/config.js +57 -37
  9. package/dist/commands/devices.js +63 -37
  10. package/dist/commands/doctor.js +539 -26
  11. package/dist/commands/events.js +115 -26
  12. package/dist/commands/expand.js +7 -15
  13. package/dist/commands/explain.js +10 -7
  14. package/dist/commands/history.js +12 -18
  15. package/dist/commands/identity.js +59 -0
  16. package/dist/commands/install.js +246 -0
  17. package/dist/commands/mcp.js +895 -15
  18. package/dist/commands/plan.js +111 -15
  19. package/dist/commands/policy.js +469 -0
  20. package/dist/commands/rules.js +657 -0
  21. package/dist/commands/schema.js +20 -12
  22. package/dist/commands/status-sync.js +131 -0
  23. package/dist/commands/uninstall.js +237 -0
  24. package/dist/commands/watch.js +15 -2
  25. package/dist/config.js +14 -0
  26. package/dist/credentials/backends/file.js +101 -0
  27. package/dist/credentials/backends/linux.js +129 -0
  28. package/dist/credentials/backends/macos.js +129 -0
  29. package/dist/credentials/backends/windows.js +215 -0
  30. package/dist/credentials/keychain.js +88 -0
  31. package/dist/credentials/prime.js +52 -0
  32. package/dist/devices/catalog.js +118 -11
  33. package/dist/devices/resources.js +270 -0
  34. package/dist/index.js +39 -4
  35. package/dist/install/default-steps.js +257 -0
  36. package/dist/install/preflight.js +212 -0
  37. package/dist/install/steps.js +67 -0
  38. package/dist/lib/command-keywords.js +17 -0
  39. package/dist/lib/devices.js +15 -5
  40. package/dist/policy/add-rule.js +124 -0
  41. package/dist/policy/diff.js +91 -0
  42. package/dist/policy/examples/policy.example.yaml +99 -0
  43. package/dist/policy/format.js +57 -0
  44. package/dist/policy/load.js +61 -0
  45. package/dist/policy/migrate.js +67 -0
  46. package/dist/policy/schema/v0.2.json +302 -0
  47. package/dist/policy/schema.js +18 -0
  48. package/dist/policy/validate.js +262 -0
  49. package/dist/rules/action.js +205 -0
  50. package/dist/rules/audit-query.js +89 -0
  51. package/dist/rules/cron-scheduler.js +186 -0
  52. package/dist/rules/destructive.js +52 -0
  53. package/dist/rules/engine.js +567 -0
  54. package/dist/rules/matcher.js +230 -0
  55. package/dist/rules/pid-file.js +95 -0
  56. package/dist/rules/quiet-hours.js +45 -0
  57. package/dist/rules/suggest.js +95 -0
  58. package/dist/rules/throttle.js +78 -0
  59. package/dist/rules/types.js +34 -0
  60. package/dist/rules/webhook-listener.js +223 -0
  61. package/dist/rules/webhook-token.js +90 -0
  62. package/dist/schema/field-aliases.js +95 -0
  63. package/dist/status-sync/manager.js +268 -0
  64. package/dist/utils/audit.js +12 -2
  65. package/dist/utils/help-json.js +54 -0
  66. package/dist/utils/output.js +17 -0
  67. package/package.json +12 -4
@@ -7,7 +7,7 @@ import { handleError, buildErrorPayload, exitWithError } from '../utils/output.j
7
7
  import { VERSION } from '../version.js';
8
8
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
9
9
  import { fetchScenes, executeScene } from '../lib/scenes.js';
10
- import { findCatalogEntry } from '../devices/catalog.js';
10
+ import { findCatalogEntry, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
11
11
  import { getCachedDevice } from '../devices/cache.js';
12
12
  import { validateParameter } from '../devices/param-validator.js';
13
13
  import { EventSubscriptionManager } from '../mcp/events-subscription.js';
@@ -18,6 +18,24 @@ import { todayUsage } from '../utils/quota.js';
18
18
  import { describeCache } from '../devices/cache.js';
19
19
  import { withRequestContext } from '../lib/request-context.js';
20
20
  import { profileFilePath, tryLoadConfig } from '../config.js';
21
+ import { loadPolicyFile, resolvePolicyPath, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
22
+ import { validateLoadedPolicy } from '../policy/validate.js';
23
+ import { CURRENT_POLICY_SCHEMA_VERSION, SUPPORTED_POLICY_SCHEMA_VERSIONS, } from '../policy/schema.js';
24
+ import { planMigration } from '../policy/migrate.js';
25
+ import { suggestPlan } from './plan.js';
26
+ import { suggestRule } from '../rules/suggest.js';
27
+ import { addRuleToPolicyFile, AddRuleError } from '../policy/add-rule.js';
28
+ import { writeFileSync } from 'node:fs';
29
+ import { readAudit } from '../utils/audit.js';
30
+ import { parseDurationToMs } from '../utils/flags.js';
31
+ import { resolveDeviceId } from '../utils/name-resolver.js';
32
+ import { validatePlan } from './plan.js';
33
+ import { parse as yamlParse } from 'yaml';
34
+ import { diffPolicyValues } from '../policy/diff.js';
35
+ const LATEST_SUPPORTED_VERSION = SUPPORTED_POLICY_SCHEMA_VERSIONS[SUPPORTED_POLICY_SCHEMA_VERSIONS.length - 1];
36
+ import { fileURLToPath } from 'node:url';
37
+ import { dirname as pathDirname, join as pathJoin } from 'node:path';
38
+ import os from 'node:os';
21
39
  import fs from 'node:fs';
22
40
  function mcpError(kind, code, message, options) {
23
41
  const obj = { code, kind, message };
@@ -57,6 +75,66 @@ function apiErrorToMcpError(err) {
57
75
  retryAfterMs: payload.retryAfterMs,
58
76
  });
59
77
  }
78
+ const DEFAULT_AUDIT_LOG_FILE = pathJoin(os.homedir(), '.switchbot', 'audit.log');
79
+ function resolveAuditRange(opts) {
80
+ if (opts.since && (opts.from || opts.to)) {
81
+ throw new Error('--since is mutually exclusive with --from/--to.');
82
+ }
83
+ if (opts.since) {
84
+ const dur = parseDurationToMs(opts.since);
85
+ if (dur === null) {
86
+ throw new Error(`Invalid --since value "${opts.since}". Expected e.g. "30s", "15m", "1h", "7d".`);
87
+ }
88
+ return { fromMs: Date.now() - dur, toMs: Number.POSITIVE_INFINITY };
89
+ }
90
+ let fromMs = Number.NEGATIVE_INFINITY;
91
+ let toMs = Number.POSITIVE_INFINITY;
92
+ if (opts.from) {
93
+ const parsed = Date.parse(opts.from);
94
+ if (!Number.isFinite(parsed)) {
95
+ throw new Error(`Invalid --from value "${opts.from}". Expected ISO-8601 timestamp.`);
96
+ }
97
+ fromMs = parsed;
98
+ }
99
+ if (opts.to) {
100
+ const parsed = Date.parse(opts.to);
101
+ if (!Number.isFinite(parsed)) {
102
+ throw new Error(`Invalid --to value "${opts.to}". Expected ISO-8601 timestamp.`);
103
+ }
104
+ toMs = parsed;
105
+ }
106
+ if (fromMs > toMs) {
107
+ throw new Error('--from must be <= --to.');
108
+ }
109
+ return { fromMs, toMs };
110
+ }
111
+ function filterAuditEntries(entries, opts) {
112
+ const { fromMs, toMs } = resolveAuditRange(opts);
113
+ return entries.filter((entry) => {
114
+ const tMs = Date.parse(entry.t);
115
+ if (!Number.isFinite(tMs))
116
+ return false;
117
+ if (tMs < fromMs || tMs > toMs)
118
+ return false;
119
+ if (opts.kinds && opts.kinds.length > 0 && !opts.kinds.includes(entry.kind))
120
+ return false;
121
+ if (opts.deviceId && entry.deviceId !== opts.deviceId)
122
+ return false;
123
+ if (opts.ruleName && entry.rule?.name !== opts.ruleName)
124
+ return false;
125
+ if (opts.results && opts.results.length > 0) {
126
+ if (!entry.result || !opts.results.includes(entry.result))
127
+ return false;
128
+ }
129
+ return true;
130
+ });
131
+ }
132
+ function topNFromMap(counts, n) {
133
+ return [...counts.entries()]
134
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
135
+ .slice(0, n)
136
+ .map(([key, count]) => ({ key, count }));
137
+ }
60
138
  export function createSwitchBotMcpServer(options) {
61
139
  const eventManager = options?.eventManager;
62
140
  const server = new McpServer({
@@ -362,7 +440,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
362
440
  command: effectiveCommand,
363
441
  deviceType: typeName,
364
442
  description: spec?.description ?? null,
365
- ...(reason ? { destructiveReason: reason } : {}),
443
+ ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}),
366
444
  },
367
445
  });
368
446
  }
@@ -516,7 +594,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
516
594
  description: z.string(),
517
595
  commandType: z.enum(['command', 'customize']).optional(),
518
596
  idempotent: z.boolean().optional(),
519
- destructive: z.boolean().optional(),
597
+ safetyTier: z.enum(['read', 'mutation', 'ir-fire-forget', 'destructive', 'maintenance']).optional(),
598
+ safetyReason: z.string().optional(),
520
599
  }).passthrough()),
521
600
  aliases: z.array(z.string()).optional(),
522
601
  statusFields: z.array(z.string()).optional(),
@@ -532,9 +611,21 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
532
611
  });
533
612
  }
534
613
  const hits = searchCatalog(query, limit);
535
- const structured = { results: hits, total: hits.length };
614
+ const normalised = hits.map((e) => ({
615
+ ...e,
616
+ commands: e.commands.map((c) => {
617
+ const tier = deriveSafetyTier(c, e);
618
+ const reason = getCommandSafetyReason(c);
619
+ return {
620
+ ...c,
621
+ safetyTier: tier,
622
+ ...(reason ? { safetyReason: reason } : {}),
623
+ };
624
+ }),
625
+ }));
626
+ const structured = { results: normalised, total: normalised.length };
536
627
  return {
537
- content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }],
628
+ content: [{ type: 'text', text: JSON.stringify(normalised, null, 2) }],
538
629
  structuredContent: structured,
539
630
  };
540
631
  });
@@ -591,16 +682,64 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
591
682
  _meta: { agentSafetyTier: 'read' },
592
683
  inputSchema: z
593
684
  .object({
594
- deviceId: z.string().min(1),
595
- since: z.string().optional(),
596
- from: z.string().optional(),
597
- to: z.string().optional(),
598
- metrics: z.array(z.string().min(1)).min(1),
599
- aggs: z.array(z.enum(ALL_AGG_FNS)).optional(),
600
- bucket: z.string().optional(),
601
- maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional(),
685
+ deviceId: z.string().min(1).describe('Device ID to aggregate over (must exist in ~/.switchbot/device-history/).'),
686
+ since: z
687
+ .string()
688
+ .optional()
689
+ .describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
690
+ from: z.string().optional().describe('Range start (ISO-8601). Requires `to`.'),
691
+ to: z.string().optional().describe('Range end (ISO-8601). Requires `from`.'),
692
+ metrics: z
693
+ .array(z.string().min(1))
694
+ .min(1)
695
+ .describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'),
696
+ aggs: z
697
+ .array(z.enum(ALL_AGG_FNS))
698
+ .optional()
699
+ .describe('Aggregation functions to apply per metric (default: ["count","avg"]).'),
700
+ bucket: z
701
+ .string()
702
+ .optional()
703
+ .describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'),
704
+ maxBucketSamples: z
705
+ .number()
706
+ .int()
707
+ .positive()
708
+ .max(MAX_SAMPLE_CAP)
709
+ .optional()
710
+ .describe(`Sample cap per bucket to bound memory (default ${10_000}, max ${MAX_SAMPLE_CAP}). partial=true in the result when any bucket was capped.`),
602
711
  })
603
712
  .strict(),
713
+ outputSchema: {
714
+ deviceId: z.string(),
715
+ bucket: z.string().optional().describe('Bucket width echoed back when specified; omitted for single-bucket results.'),
716
+ from: z.string().describe('Effective range start (ISO-8601).'),
717
+ to: z.string().describe('Effective range end (ISO-8601).'),
718
+ metrics: z.array(z.string()).describe('Metrics that were requested.'),
719
+ aggs: z
720
+ .array(z.enum(ALL_AGG_FNS))
721
+ .describe('Aggregation functions that were applied.'),
722
+ buckets: z
723
+ .array(z.object({
724
+ t: z.string().describe('Bucket start timestamp (ISO-8601).'),
725
+ metrics: z
726
+ .record(z.string(), z
727
+ .object({
728
+ count: z.number().optional(),
729
+ min: z.number().optional(),
730
+ max: z.number().optional(),
731
+ avg: z.number().optional(),
732
+ sum: z.number().optional(),
733
+ p50: z.number().optional(),
734
+ p95: z.number().optional(),
735
+ })
736
+ .describe('Per-aggregate function result for this metric in this bucket.'))
737
+ .describe('Per-metric result keyed by metric name.'),
738
+ }))
739
+ .describe('Time-ordered buckets; empty when no records match.'),
740
+ partial: z.boolean().describe('True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values.'),
741
+ notes: z.array(z.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").'),
742
+ },
604
743
  }, async (args) => {
605
744
  const opts = {
606
745
  since: args.since,
@@ -612,9 +751,21 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
612
751
  maxBucketSamples: args.maxBucketSamples,
613
752
  };
614
753
  const res = await aggregateDeviceHistory(args.deviceId, opts);
754
+ const structured = {
755
+ deviceId: res.deviceId,
756
+ from: res.from,
757
+ to: res.to,
758
+ metrics: res.metrics,
759
+ aggs: res.aggs,
760
+ buckets: res.buckets,
761
+ partial: res.partial,
762
+ notes: res.notes,
763
+ };
764
+ if (res.bucket !== undefined)
765
+ structured.bucket = res.bucket;
615
766
  return {
616
767
  content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
617
- structuredContent: res,
768
+ structuredContent: structured,
618
769
  };
619
770
  });
620
771
  // ---- account_overview ---------------------------------------------------
@@ -706,6 +857,347 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
706
857
  structuredContent: overview,
707
858
  };
708
859
  });
860
+ // ---- policy_validate -----------------------------------------------------
861
+ server.registerTool('policy_validate', {
862
+ title: 'Validate a policy.yaml file',
863
+ description: 'Check a policy file against the embedded JSON Schema (supports v0.1 and v0.2). ' +
864
+ 'Returns the validation result with per-error line/col and a hint. ' +
865
+ 'When no path is given, reads the resolved default (${SWITCHBOT_POLICY_PATH} or ~/.config/openclaw/switchbot/policy.yaml). ' +
866
+ 'Use before relying on aliases/quiet_hours/confirmations so the agent never acts on a broken policy.',
867
+ _meta: { agentSafetyTier: 'read' },
868
+ inputSchema: z.object({
869
+ path: z.string().optional().describe('Optional policy file path; defaults to the resolved default path'),
870
+ }).strict(),
871
+ outputSchema: {
872
+ policyPath: z.string(),
873
+ schemaVersion: z.string(),
874
+ present: z.boolean().describe('false when the file does not exist'),
875
+ valid: z.boolean().nullable().describe('null when present=false'),
876
+ errors: z.array(z.object({
877
+ path: z.string(),
878
+ line: z.number().optional(),
879
+ col: z.number().optional(),
880
+ keyword: z.string(),
881
+ message: z.string(),
882
+ hint: z.string().optional(),
883
+ schemaPath: z.string(),
884
+ })).describe('Empty when valid or when the file is missing'),
885
+ },
886
+ }, async ({ path: pathArg }) => {
887
+ const policyPath = resolvePolicyPath({ flag: pathArg });
888
+ try {
889
+ const loaded = loadPolicyFile(policyPath);
890
+ const result = validateLoadedPolicy(loaded);
891
+ const structured = {
892
+ policyPath: result.policyPath,
893
+ schemaVersion: result.schemaVersion,
894
+ present: true,
895
+ valid: result.valid,
896
+ errors: result.errors,
897
+ };
898
+ return {
899
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
900
+ structuredContent: structured,
901
+ };
902
+ }
903
+ catch (err) {
904
+ if (err instanceof PolicyFileNotFoundError) {
905
+ const structured = {
906
+ policyPath,
907
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
908
+ present: false,
909
+ valid: null,
910
+ errors: [],
911
+ };
912
+ return {
913
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
914
+ structuredContent: structured,
915
+ };
916
+ }
917
+ if (err instanceof PolicyYamlParseError) {
918
+ const structured = {
919
+ policyPath,
920
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
921
+ present: true,
922
+ valid: false,
923
+ errors: err.yamlErrors.map((e) => ({
924
+ path: '',
925
+ line: e.line,
926
+ col: e.col,
927
+ keyword: 'yaml-parse',
928
+ message: e.message,
929
+ schemaPath: '',
930
+ })),
931
+ };
932
+ return {
933
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
934
+ structuredContent: structured,
935
+ };
936
+ }
937
+ throw err;
938
+ }
939
+ });
940
+ // ---- policy_new ----------------------------------------------------------
941
+ server.registerTool('policy_new', {
942
+ title: 'Scaffold a starter policy.yaml',
943
+ description: 'Write a starter policy file to the resolved default path (or a given path). Refuses to overwrite unless force=true. ' +
944
+ 'This is a write action: the agent should only call it after confirming with the user.',
945
+ _meta: { agentSafetyTier: 'action' },
946
+ inputSchema: z.object({
947
+ path: z.string().optional().describe('Optional target path; defaults to the resolved default'),
948
+ force: z.boolean().optional().describe('When true, overwrite an existing file'),
949
+ }).strict(),
950
+ outputSchema: {
951
+ policyPath: z.string(),
952
+ schemaVersion: z.string(),
953
+ bytesWritten: z.number(),
954
+ overwritten: z.boolean(),
955
+ },
956
+ }, async ({ path: pathArg, force }) => {
957
+ const policyPath = resolvePolicyPath({ flag: pathArg });
958
+ const doForce = force === true;
959
+ if (fs.existsSync(policyPath) && !doForce) {
960
+ return mcpError('guard', 5, `refusing to overwrite existing policy at ${policyPath}`, {
961
+ hint: 'pass force=true to overwrite, or choose a different path',
962
+ context: { policyPath },
963
+ });
964
+ }
965
+ const templateUrl = new URL('../policy/examples/policy.example.yaml', import.meta.url);
966
+ const template = fs.readFileSync(fileURLToPath(templateUrl), 'utf-8');
967
+ fs.mkdirSync(pathDirname(policyPath), { recursive: true });
968
+ fs.writeFileSync(policyPath, template, { encoding: 'utf-8' });
969
+ const structured = {
970
+ policyPath,
971
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
972
+ bytesWritten: Buffer.byteLength(template, 'utf-8'),
973
+ overwritten: doForce,
974
+ };
975
+ return {
976
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
977
+ structuredContent: structured,
978
+ };
979
+ });
980
+ // ---- policy_migrate ------------------------------------------------------
981
+ server.registerTool('policy_migrate', {
982
+ title: 'Migrate a policy file to the latest supported schema',
983
+ description: 'Upgrades the policy file\'s schema version in place while preserving comments. ' +
984
+ 'Safe by default: if the migrated document would fail schema validation, the file is NOT rewritten ' +
985
+ 'and the tool returns status="precheck-failed" with the list of errors. ' +
986
+ 'Pass dryRun=true to preview without touching the file. ' +
987
+ 'Currently the only supported upgrade path is v0.1 → v0.2.',
988
+ _meta: { agentSafetyTier: 'action' },
989
+ inputSchema: z.object({
990
+ path: z.string().optional().describe('Optional policy file path; defaults to the resolved default path'),
991
+ dryRun: z.boolean().optional().describe('When true, report what would change without writing'),
992
+ to: z.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`),
993
+ }).strict(),
994
+ outputSchema: {
995
+ policyPath: z.string(),
996
+ fileVersion: z.string().optional(),
997
+ targetVersion: z.string(),
998
+ supportedVersions: z.array(z.string()),
999
+ status: z.enum([
1000
+ 'already-current',
1001
+ 'migrated',
1002
+ 'dry-run',
1003
+ 'no-version-field',
1004
+ 'unsupported',
1005
+ 'precheck-failed',
1006
+ 'file-not-found',
1007
+ ]),
1008
+ from: z.string().optional(),
1009
+ to: z.string().optional(),
1010
+ bytesWritten: z.number().optional(),
1011
+ message: z.string(),
1012
+ errors: z
1013
+ .array(z.object({ path: z.string(), keyword: z.string(), message: z.string() }))
1014
+ .optional(),
1015
+ },
1016
+ }, async ({ path: pathArg, dryRun, to }) => {
1017
+ const policyPath = resolvePolicyPath({ flag: pathArg });
1018
+ const target = (to ?? LATEST_SUPPORTED_VERSION);
1019
+ let loaded;
1020
+ try {
1021
+ loaded = loadPolicyFile(policyPath);
1022
+ }
1023
+ catch (err) {
1024
+ if (err instanceof PolicyFileNotFoundError) {
1025
+ const structured = {
1026
+ policyPath,
1027
+ targetVersion: target,
1028
+ supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
1029
+ status: 'file-not-found',
1030
+ message: `policy file not found: ${policyPath}`,
1031
+ };
1032
+ return {
1033
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1034
+ structuredContent: structured,
1035
+ };
1036
+ }
1037
+ throw err;
1038
+ }
1039
+ const data = loaded.data;
1040
+ const fileVersion = typeof data?.version === 'string' ? data.version : undefined;
1041
+ const base = {
1042
+ policyPath,
1043
+ fileVersion,
1044
+ targetVersion: target,
1045
+ supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
1046
+ };
1047
+ if (!fileVersion) {
1048
+ const structured = {
1049
+ ...base,
1050
+ status: 'no-version-field',
1051
+ message: `policy has no \`version\` field — add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\``,
1052
+ };
1053
+ return {
1054
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1055
+ structuredContent: structured,
1056
+ };
1057
+ }
1058
+ if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
1059
+ const structured = {
1060
+ ...base,
1061
+ status: 'unsupported',
1062
+ message: `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(', ')})`,
1063
+ };
1064
+ return {
1065
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1066
+ structuredContent: structured,
1067
+ };
1068
+ }
1069
+ if (fileVersion === target) {
1070
+ const structured = {
1071
+ ...base,
1072
+ status: 'already-current',
1073
+ message: `already on schema v${target}; no migration needed`,
1074
+ bytesWritten: 0,
1075
+ };
1076
+ return {
1077
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1078
+ structuredContent: structured,
1079
+ };
1080
+ }
1081
+ const plan = planMigration(loaded, fileVersion, target);
1082
+ if (!plan.precheck.valid) {
1083
+ const structured = {
1084
+ ...base,
1085
+ status: 'precheck-failed',
1086
+ message: `migrated policy fails schema v${target} precheck; file not written`,
1087
+ errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message })),
1088
+ };
1089
+ return {
1090
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1091
+ structuredContent: structured,
1092
+ };
1093
+ }
1094
+ const bytes = Buffer.byteLength(plan.nextSource, 'utf-8');
1095
+ if (dryRun) {
1096
+ const structured = {
1097
+ ...base,
1098
+ status: 'dry-run',
1099
+ from: plan.fromVersion,
1100
+ to: plan.toVersion,
1101
+ bytesWritten: 0,
1102
+ message: `dry-run: would upgrade v${plan.fromVersion} → v${plan.toVersion} (${bytes} bytes)`,
1103
+ };
1104
+ return {
1105
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1106
+ structuredContent: structured,
1107
+ };
1108
+ }
1109
+ writeFileSync(policyPath, plan.nextSource, { encoding: 'utf-8' });
1110
+ const structured = {
1111
+ ...base,
1112
+ status: 'migrated',
1113
+ from: plan.fromVersion,
1114
+ to: plan.toVersion,
1115
+ bytesWritten: bytes,
1116
+ message: `migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`,
1117
+ };
1118
+ return {
1119
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1120
+ structuredContent: structured,
1121
+ };
1122
+ });
1123
+ // ---- policy_diff ---------------------------------------------------------
1124
+ server.registerTool('policy_diff', {
1125
+ title: 'Compare two policy files',
1126
+ description: 'Compare two policy YAML files and return the same contract as `switchbot --json policy diff`: ' +
1127
+ '{ leftPath, rightPath, equal, changeCount, truncated, stats, changes, diff }.',
1128
+ _meta: { agentSafetyTier: 'read' },
1129
+ inputSchema: z.object({
1130
+ left_path: z.string().min(1).describe('Path to the baseline policy file.'),
1131
+ right_path: z.string().min(1).describe('Path to the candidate policy file.'),
1132
+ }).strict(),
1133
+ outputSchema: {
1134
+ leftPath: z.string(),
1135
+ rightPath: z.string(),
1136
+ equal: z.boolean(),
1137
+ changeCount: z.number().int(),
1138
+ truncated: z.boolean(),
1139
+ stats: z.object({
1140
+ added: z.number().int(),
1141
+ removed: z.number().int(),
1142
+ changed: z.number().int(),
1143
+ }),
1144
+ changes: z.array(z.object({
1145
+ path: z.string(),
1146
+ kind: z.enum(['added', 'removed', 'changed']),
1147
+ before: z.unknown().optional(),
1148
+ after: z.unknown().optional(),
1149
+ })),
1150
+ diff: z.string(),
1151
+ },
1152
+ }, ({ left_path, right_path }) => {
1153
+ let leftSource = '';
1154
+ let rightSource = '';
1155
+ try {
1156
+ leftSource = fs.readFileSync(left_path, 'utf-8');
1157
+ }
1158
+ catch (err) {
1159
+ if (err?.code === 'ENOENT') {
1160
+ return mcpError('usage', 2, `policy file not found: ${left_path}`, {
1161
+ context: { policyPath: left_path },
1162
+ });
1163
+ }
1164
+ return mcpError('runtime', 1, `failed to read ${left_path}: ${String(err)}`);
1165
+ }
1166
+ try {
1167
+ rightSource = fs.readFileSync(right_path, 'utf-8');
1168
+ }
1169
+ catch (err) {
1170
+ if (err?.code === 'ENOENT') {
1171
+ return mcpError('usage', 2, `policy file not found: ${right_path}`, {
1172
+ context: { policyPath: right_path },
1173
+ });
1174
+ }
1175
+ return mcpError('runtime', 1, `failed to read ${right_path}: ${String(err)}`);
1176
+ }
1177
+ let leftDoc;
1178
+ let rightDoc;
1179
+ try {
1180
+ leftDoc = yamlParse(leftSource);
1181
+ }
1182
+ catch (err) {
1183
+ return mcpError('usage', 2, `YAML parse error in ${left_path}: ${err.message}`);
1184
+ }
1185
+ try {
1186
+ rightDoc = yamlParse(rightSource);
1187
+ }
1188
+ catch (err) {
1189
+ return mcpError('usage', 2, `YAML parse error in ${right_path}: ${err.message}`);
1190
+ }
1191
+ const result = {
1192
+ leftPath: left_path,
1193
+ rightPath: right_path,
1194
+ ...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource),
1195
+ };
1196
+ return {
1197
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1198
+ structuredContent: result,
1199
+ };
1200
+ });
709
1201
  // switchbot://events resource — snapshot of recent shadow events from the ring buffer.
710
1202
  // Returns up to 100 recent events. When MQTT is disabled, returns an empty list with a state note.
711
1203
  // URI: switchbot://events (optional query: ?filter=<expression> ?limit=<n>)
@@ -727,14 +1219,392 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
727
1219
  };
728
1220
  });
729
1221
  }
1222
+ // ---- plan_suggest ---------------------------------------------------------
1223
+ server.registerTool('plan_suggest', {
1224
+ title: 'Draft a SwitchBot execution plan from intent',
1225
+ description: 'Generate a candidate Plan JSON from a natural language intent and a list of device IDs. ' +
1226
+ 'Uses keyword heuristics (no LLM) to pick the command. The returned plan is ready to pass to ' +
1227
+ '`plan run` — review and edit before executing. Recognised commands: turnOn, turnOff, press, ' +
1228
+ 'lock, unlock, open, close, pause. Falls back to turnOn with a warning when intent is unclear.',
1229
+ _meta: { agentSafetyTier: 'read' },
1230
+ inputSchema: z.object({
1231
+ intent: z.string().min(1).describe('Natural language description of what to do (e.g. "turn off all lights").'),
1232
+ device_ids: z.array(z.string().min(1)).min(1).describe('Device IDs to act on.'),
1233
+ }).strict(),
1234
+ outputSchema: {
1235
+ plan: z.unknown().describe('Candidate Plan JSON (version 1.0) ready to pass to plan run.'),
1236
+ warnings: z.array(z.string()).describe('Informational warnings (e.g. unrecognized intent defaulted to turnOn).'),
1237
+ },
1238
+ }, ({ intent, device_ids }) => {
1239
+ const devices = device_ids.map((id) => {
1240
+ const cached = getCachedDevice(id);
1241
+ return { id, name: cached?.name, type: cached?.type };
1242
+ });
1243
+ try {
1244
+ const { plan, warnings } = suggestPlan({ intent, devices });
1245
+ return {
1246
+ content: [{ type: 'text', text: JSON.stringify({ plan, warnings }, null, 2) }],
1247
+ structuredContent: { plan, warnings },
1248
+ };
1249
+ }
1250
+ catch (err) {
1251
+ return apiErrorToMcpError(err);
1252
+ }
1253
+ });
1254
+ // ---- plan_run -------------------------------------------------------------
1255
+ server.registerTool('plan_run', {
1256
+ title: 'Validate and execute a SwitchBot plan',
1257
+ description: 'Execute a Plan JSON object (version 1.0). Destructive command steps are skipped unless yes=true. ' +
1258
+ 'Scene and wait steps run in order. Returns per-step results and a summary.',
1259
+ _meta: { agentSafetyTier: 'action' },
1260
+ inputSchema: z.object({
1261
+ plan: z.unknown().describe('Plan JSON object (same schema as `switchbot plan run`).'),
1262
+ yes: z.boolean().optional().describe('Authorize destructive command steps.'),
1263
+ continue_on_error: z.boolean().optional().describe('Keep executing later steps after a failed step.'),
1264
+ }).strict(),
1265
+ outputSchema: {
1266
+ ran: z.boolean(),
1267
+ plan: z.unknown(),
1268
+ results: z.array(z.unknown()),
1269
+ summary: z.object({
1270
+ total: z.number().int(),
1271
+ ok: z.number().int(),
1272
+ error: z.number().int(),
1273
+ skipped: z.number().int(),
1274
+ }),
1275
+ },
1276
+ }, async ({ plan, yes, continue_on_error }) => {
1277
+ const validated = validatePlan(plan);
1278
+ if (!validated.ok) {
1279
+ return mcpError('usage', 2, 'plan invalid', {
1280
+ context: { issues: validated.issues },
1281
+ hint: 'Fix the reported issues and retry plan_run.',
1282
+ });
1283
+ }
1284
+ const out = {
1285
+ ran: true,
1286
+ plan: validated.plan,
1287
+ results: [],
1288
+ summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 },
1289
+ };
1290
+ const continueOnError = continue_on_error === true;
1291
+ const allowDestructive = yes === true;
1292
+ for (let i = 0; i < validated.plan.steps.length; i++) {
1293
+ const step = validated.plan.steps[i];
1294
+ const idx = i + 1;
1295
+ if (step.type === 'wait') {
1296
+ await new Promise((resolve) => setTimeout(resolve, step.ms));
1297
+ out.results.push({ step: idx, type: 'wait', ms: step.ms, status: 'ok' });
1298
+ out.summary.ok++;
1299
+ continue;
1300
+ }
1301
+ if (step.type === 'scene') {
1302
+ try {
1303
+ await executeScene(step.sceneId);
1304
+ out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'ok' });
1305
+ out.summary.ok++;
1306
+ }
1307
+ catch (err) {
1308
+ const msg = err instanceof Error ? err.message : String(err);
1309
+ out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'error', error: msg });
1310
+ out.summary.error++;
1311
+ if (!continueOnError)
1312
+ break;
1313
+ }
1314
+ continue;
1315
+ }
1316
+ let resolvedDeviceId = '';
1317
+ try {
1318
+ resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
1319
+ const commandType = step.commandType ?? 'command';
1320
+ const deviceType = getCachedDevice(resolvedDeviceId)?.type;
1321
+ const destructive = isDestructiveCommand(deviceType, step.command, commandType);
1322
+ if (destructive && !allowDestructive) {
1323
+ out.results.push({
1324
+ step: idx,
1325
+ type: 'command',
1326
+ deviceId: resolvedDeviceId,
1327
+ command: step.command,
1328
+ status: 'skipped',
1329
+ error: 'destructive — rerun with yes=true',
1330
+ });
1331
+ out.summary.skipped++;
1332
+ if (!continueOnError)
1333
+ break;
1334
+ continue;
1335
+ }
1336
+ await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
1337
+ out.results.push({
1338
+ step: idx,
1339
+ type: 'command',
1340
+ deviceId: resolvedDeviceId,
1341
+ command: step.command,
1342
+ status: 'ok',
1343
+ });
1344
+ out.summary.ok++;
1345
+ }
1346
+ catch (err) {
1347
+ if (err instanceof Error && err.name === 'DryRunSignal') {
1348
+ out.results.push({
1349
+ step: idx,
1350
+ type: 'command',
1351
+ deviceId: resolvedDeviceId || step.deviceId || 'unknown',
1352
+ command: step.command,
1353
+ status: 'ok',
1354
+ });
1355
+ out.summary.ok++;
1356
+ continue;
1357
+ }
1358
+ const msg = err instanceof Error ? err.message : String(err);
1359
+ out.results.push({
1360
+ step: idx,
1361
+ type: 'command',
1362
+ deviceId: resolvedDeviceId || step.deviceId || 'unknown',
1363
+ command: step.command,
1364
+ status: 'error',
1365
+ error: msg,
1366
+ });
1367
+ out.summary.error++;
1368
+ if (!continueOnError)
1369
+ break;
1370
+ }
1371
+ }
1372
+ return {
1373
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1374
+ structuredContent: out,
1375
+ };
1376
+ });
1377
+ // ---- audit_query ----------------------------------------------------------
1378
+ server.registerTool('audit_query', {
1379
+ title: 'Query command/rule audit log entries',
1380
+ description: 'Filter entries from the local audit log (default ~/.switchbot/audit.log) by time range, kind, device, rule, and result. ' +
1381
+ 'Useful for review flows and rule-fire inspection without leaving MCP.',
1382
+ _meta: { agentSafetyTier: 'read' },
1383
+ inputSchema: z.object({
1384
+ file: z.string().optional().describe('Optional audit log path; defaults to ~/.switchbot/audit.log.'),
1385
+ since: z.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
1386
+ from: z.string().optional().describe('Range start (ISO-8601).'),
1387
+ to: z.string().optional().describe('Range end (ISO-8601).'),
1388
+ kinds: z.array(z.enum(['command', 'rule-fire', 'rule-fire-dry', 'rule-throttled', 'rule-webhook-rejected'])).optional().describe('Filter by entry kind.'),
1389
+ device_id: z.string().optional().describe('Filter by deviceId.'),
1390
+ rule_name: z.string().optional().describe('Filter by rule.name (rule-engine entries).'),
1391
+ results: z.array(z.enum(['ok', 'error'])).optional().describe('Filter by execution result.'),
1392
+ limit: z.number().int().min(1).max(5000).optional().describe('Max entries returned from the tail of the filtered set (default 200).'),
1393
+ }).strict(),
1394
+ outputSchema: {
1395
+ file: z.string(),
1396
+ totalMatched: z.number().int(),
1397
+ returned: z.number().int(),
1398
+ entries: z.array(z.unknown()),
1399
+ },
1400
+ }, ({ file, since, from, to, kinds, device_id, rule_name, results, limit }) => {
1401
+ const filePath = file ?? DEFAULT_AUDIT_LOG_FILE;
1402
+ const entries = readAudit(filePath);
1403
+ try {
1404
+ const filtered = filterAuditEntries(entries, {
1405
+ since,
1406
+ from,
1407
+ to,
1408
+ kinds,
1409
+ deviceId: device_id,
1410
+ ruleName: rule_name,
1411
+ results,
1412
+ });
1413
+ const bounded = filtered.slice(-Math.max(1, limit ?? 200));
1414
+ const out = {
1415
+ file: filePath,
1416
+ totalMatched: filtered.length,
1417
+ returned: bounded.length,
1418
+ entries: bounded,
1419
+ };
1420
+ return {
1421
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1422
+ structuredContent: out,
1423
+ };
1424
+ }
1425
+ catch (err) {
1426
+ return mcpError('usage', 2, err instanceof Error ? err.message : 'invalid audit query options');
1427
+ }
1428
+ });
1429
+ // ---- audit_stats ----------------------------------------------------------
1430
+ server.registerTool('audit_stats', {
1431
+ title: 'Aggregate audit log counts for review dashboards',
1432
+ description: 'Compute summary counters over the local audit log: by kind, by result, top devices, and top rules. ' +
1433
+ 'Supports the same filters as audit_query.',
1434
+ _meta: { agentSafetyTier: 'read' },
1435
+ inputSchema: z.object({
1436
+ file: z.string().optional().describe('Optional audit log path; defaults to ~/.switchbot/audit.log.'),
1437
+ since: z.string().optional().describe('Relative window ending now (e.g. "6h"). Mutually exclusive with from/to.'),
1438
+ from: z.string().optional().describe('Range start (ISO-8601).'),
1439
+ to: z.string().optional().describe('Range end (ISO-8601).'),
1440
+ kinds: z.array(z.enum(['command', 'rule-fire', 'rule-fire-dry', 'rule-throttled', 'rule-webhook-rejected'])).optional().describe('Filter by entry kind.'),
1441
+ device_id: z.string().optional().describe('Filter by deviceId.'),
1442
+ rule_name: z.string().optional().describe('Filter by rule.name (rule-engine entries).'),
1443
+ results: z.array(z.enum(['ok', 'error'])).optional().describe('Filter by execution result.'),
1444
+ top_n: z.number().int().min(1).max(100).optional().describe('Number of top device/rule rows to return (default 10).'),
1445
+ }).strict(),
1446
+ outputSchema: {
1447
+ file: z.string(),
1448
+ totalMatched: z.number().int(),
1449
+ byKind: z.record(z.string(), z.number().int()),
1450
+ byResult: z.record(z.string(), z.number().int()),
1451
+ topDevices: z.array(z.object({ deviceId: z.string(), count: z.number().int() })),
1452
+ topRules: z.array(z.object({ ruleName: z.string(), count: z.number().int() })),
1453
+ },
1454
+ }, ({ file, since, from, to, kinds, device_id, rule_name, results, top_n }) => {
1455
+ const filePath = file ?? DEFAULT_AUDIT_LOG_FILE;
1456
+ const entries = readAudit(filePath);
1457
+ try {
1458
+ const filtered = filterAuditEntries(entries, {
1459
+ since,
1460
+ from,
1461
+ to,
1462
+ kinds,
1463
+ deviceId: device_id,
1464
+ ruleName: rule_name,
1465
+ results,
1466
+ });
1467
+ const byKind = new Map();
1468
+ const byResult = new Map();
1469
+ const byDevice = new Map();
1470
+ const byRule = new Map();
1471
+ for (const entry of filtered) {
1472
+ byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + 1);
1473
+ if (entry.result)
1474
+ byResult.set(entry.result, (byResult.get(entry.result) ?? 0) + 1);
1475
+ if (entry.deviceId)
1476
+ byDevice.set(entry.deviceId, (byDevice.get(entry.deviceId) ?? 0) + 1);
1477
+ if (entry.rule?.name)
1478
+ byRule.set(entry.rule.name, (byRule.get(entry.rule.name) ?? 0) + 1);
1479
+ }
1480
+ const topN = top_n ?? 10;
1481
+ const out = {
1482
+ file: filePath,
1483
+ totalMatched: filtered.length,
1484
+ byKind: Object.fromEntries([...byKind.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
1485
+ byResult: Object.fromEntries([...byResult.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
1486
+ topDevices: topNFromMap(byDevice, topN).map((item) => ({ deviceId: item.key, count: item.count })),
1487
+ topRules: topNFromMap(byRule, topN).map((item) => ({ ruleName: item.key, count: item.count })),
1488
+ };
1489
+ return {
1490
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1491
+ structuredContent: out,
1492
+ };
1493
+ }
1494
+ catch (err) {
1495
+ return mcpError('usage', 2, err instanceof Error ? err.message : 'invalid audit stats options');
1496
+ }
1497
+ });
1498
+ // ---- rules_suggest --------------------------------------------------------
1499
+ server.registerTool('rules_suggest', {
1500
+ title: 'Draft a SwitchBot automation rule from intent',
1501
+ description: 'Generate a candidate automation rule YAML from a natural language intent. ' +
1502
+ 'Uses keyword heuristics (no LLM) to infer trigger, schedule, and command. ' +
1503
+ 'Always emits dry_run: true — the rule must be reviewed before arming. ' +
1504
+ 'Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.',
1505
+ _meta: { agentSafetyTier: 'read' },
1506
+ inputSchema: z.object({
1507
+ intent: z.string().min(1).describe('Natural language description (e.g. "turn off lights at 10pm").'),
1508
+ trigger: z.enum(['mqtt', 'cron', 'webhook']).optional().describe('Trigger type (inferred from intent if omitted).'),
1509
+ device_ids: z.array(z.string().min(1)).optional().describe('Device IDs; first is sensor for mqtt triggers, rest are action targets.'),
1510
+ event: z.string().optional().describe('MQTT event name override (e.g. motion.detected).'),
1511
+ schedule: z.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
1512
+ days: z.array(z.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
1513
+ webhook_path: z.string().optional().describe('Webhook path override (default /action).'),
1514
+ }).strict(),
1515
+ outputSchema: {
1516
+ rule: z.unknown().describe('Rule object matching the v0.2 policy schema.'),
1517
+ rule_yaml: z.string().describe('YAML string ready to pipe to policy_add_rule.'),
1518
+ warnings: z.array(z.string()).describe('Informational warnings (e.g. unrecognized intent defaulted).'),
1519
+ },
1520
+ }, ({ intent, trigger, device_ids, event, schedule, days, webhook_path }) => {
1521
+ const devices = (device_ids ?? []).map((id) => {
1522
+ const cached = getCachedDevice(id);
1523
+ return { id, name: cached?.name, type: cached?.type };
1524
+ });
1525
+ try {
1526
+ const { rule, ruleYaml, warnings } = suggestRule({
1527
+ intent,
1528
+ trigger,
1529
+ devices,
1530
+ event,
1531
+ schedule,
1532
+ days,
1533
+ webhookPath: webhook_path,
1534
+ });
1535
+ return {
1536
+ content: [{ type: 'text', text: ruleYaml }],
1537
+ structuredContent: { rule, rule_yaml: ruleYaml, warnings },
1538
+ };
1539
+ }
1540
+ catch (err) {
1541
+ return apiErrorToMcpError(err);
1542
+ }
1543
+ });
1544
+ // ---- policy_add_rule ------------------------------------------------------
1545
+ server.registerTool('policy_add_rule', {
1546
+ title: 'Append a rule to automation.rules[] in policy.yaml',
1547
+ description: 'Inject a rule YAML snippet (as produced by rules_suggest) into the automation.rules[] ' +
1548
+ 'array in policy.yaml. Preserves existing comments and formatting. ' +
1549
+ 'Always run with dry_run: true first so the agent can show the diff for user approval. ' +
1550
+ 'Never set enable_automation: true without explicitly informing the user.',
1551
+ _meta: { agentSafetyTier: 'action' },
1552
+ inputSchema: z.object({
1553
+ rule_yaml: z.string().min(1).describe('YAML string of a single rule object (e.g. from rules_suggest).'),
1554
+ policy_path: z.string().optional().describe('Path to policy.yaml (defaults to $SWITCHBOT_POLICY_PATH or ~/.switchbot/policy.yaml).'),
1555
+ enable_automation: z.boolean().default(false).describe('If true, sets automation.enabled: true after inserting the rule.'),
1556
+ dry_run: z.boolean().default(false).describe('If true, compute and return the diff without writing to disk.'),
1557
+ force: z.boolean().default(false).describe('If true, overwrite an existing rule with the same name.'),
1558
+ }).strict(),
1559
+ outputSchema: {
1560
+ policyPath: z.string().describe('Resolved path to the policy file.'),
1561
+ ruleName: z.string().describe('Name of the rule that was (or would be) inserted.'),
1562
+ written: z.boolean().describe('True when the file was actually written.'),
1563
+ diff: z.string().describe('Unified-style diff showing lines added/removed.'),
1564
+ },
1565
+ }, ({ rule_yaml, policy_path, enable_automation, dry_run, force }) => {
1566
+ const policyPath = resolvePolicyPath({ flag: policy_path });
1567
+ try {
1568
+ const result = addRuleToPolicyFile({
1569
+ ruleYaml: rule_yaml,
1570
+ policyPath,
1571
+ enableAutomation: enable_automation,
1572
+ dryRun: dry_run,
1573
+ force,
1574
+ });
1575
+ const out = { policyPath, ruleName: result.ruleName, written: result.written, diff: result.diff };
1576
+ return {
1577
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1578
+ structuredContent: out,
1579
+ };
1580
+ }
1581
+ catch (err) {
1582
+ if (err instanceof AddRuleError) {
1583
+ return apiErrorToMcpError(new Error(`${err.code}: ${err.message}`));
1584
+ }
1585
+ return apiErrorToMcpError(err);
1586
+ }
1587
+ });
730
1588
  return server;
731
1589
  }
1590
+ /**
1591
+ * P10: list the tool names registered on an McpServer instance. Used by
1592
+ * `doctor`'s dry-run check. The MCP SDK keeps `_registeredTools` private,
1593
+ * so we reach through a narrow cast — safe because this only runs in
1594
+ * diagnostic code and the shape is stable across SDK versions.
1595
+ */
1596
+ export function listRegisteredTools(server) {
1597
+ const internal = server;
1598
+ if (!internal._registeredTools)
1599
+ return [];
1600
+ return Object.keys(internal._registeredTools).sort();
1601
+ }
732
1602
  export function registerMcpCommand(program) {
733
1603
  const mcp = program
734
1604
  .command('mcp')
735
1605
  .description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
736
1606
  .addHelpText('after', `
737
- The MCP server exposes eleven tools:
1607
+ The MCP server exposes twenty-one tools:
738
1608
  - list_devices fetch all physical + IR devices
739
1609
  - get_device_status live status for a physical device
740
1610
  - send_command control a device (destructive commands need confirm:true)
@@ -746,6 +1616,16 @@ The MCP server exposes eleven tools:
746
1616
  - get_device_history fetch raw JSONL history records for a device
747
1617
  - query_device_history filter + page history records with field/time predicates
748
1618
  - aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records
1619
+ - policy_validate check policy.yaml against the embedded schema (v0.1 / v0.2)
1620
+ - policy_new scaffold a starter policy.yaml (action — confirm first)
1621
+ - policy_migrate upgrade policy.yaml to the latest schema (action — preserves comments)
1622
+ - policy_diff compare two policy files with structural + line diff output
1623
+ - plan_suggest draft a Plan JSON from intent + device IDs (heuristic, no LLM)
1624
+ - plan_run validate + execute a Plan JSON document
1625
+ - audit_query filter audit log entries by time/device/rule/result
1626
+ - audit_stats aggregate audit counts by kind/result/device/rule
1627
+ - rules_suggest draft an automation rule YAML from intent (heuristic, no LLM)
1628
+ - policy_add_rule append a rule into automation.rules[] in policy.yaml
749
1629
 
750
1630
  Resource (read-only):
751
1631
  - switchbot://events snapshot of recent MQTT shadow events from the ring buffer