@switchbot/openapi-cli 3.2.1 → 3.3.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 (332) hide show
  1. package/README.md +3 -1
  2. package/dist/index.js +57419 -170
  3. package/package.json +9 -5
  4. package/dist/api/client.d.ts +0 -31
  5. package/dist/api/client.js +0 -236
  6. package/dist/api/client.js.map +0 -1
  7. package/dist/auth.d.ts +0 -1
  8. package/dist/auth.js +0 -21
  9. package/dist/auth.js.map +0 -1
  10. package/dist/commands/agent-bootstrap.d.ts +0 -10
  11. package/dist/commands/agent-bootstrap.js +0 -200
  12. package/dist/commands/agent-bootstrap.js.map +0 -1
  13. package/dist/commands/auth.d.ts +0 -18
  14. package/dist/commands/auth.js +0 -355
  15. package/dist/commands/auth.js.map +0 -1
  16. package/dist/commands/batch.d.ts +0 -2
  17. package/dist/commands/batch.js +0 -414
  18. package/dist/commands/batch.js.map +0 -1
  19. package/dist/commands/cache.d.ts +0 -2
  20. package/dist/commands/cache.js +0 -127
  21. package/dist/commands/cache.js.map +0 -1
  22. package/dist/commands/capabilities.d.ts +0 -31
  23. package/dist/commands/capabilities.js +0 -383
  24. package/dist/commands/capabilities.js.map +0 -1
  25. package/dist/commands/catalog.d.ts +0 -2
  26. package/dist/commands/catalog.js +0 -360
  27. package/dist/commands/catalog.js.map +0 -1
  28. package/dist/commands/completion.d.ts +0 -2
  29. package/dist/commands/completion.js +0 -386
  30. package/dist/commands/completion.js.map +0 -1
  31. package/dist/commands/config.d.ts +0 -21
  32. package/dist/commands/config.js +0 -377
  33. package/dist/commands/config.js.map +0 -1
  34. package/dist/commands/daemon.d.ts +0 -2
  35. package/dist/commands/daemon.js +0 -411
  36. package/dist/commands/daemon.js.map +0 -1
  37. package/dist/commands/device-meta.d.ts +0 -2
  38. package/dist/commands/device-meta.js +0 -160
  39. package/dist/commands/device-meta.js.map +0 -1
  40. package/dist/commands/devices.d.ts +0 -2
  41. package/dist/commands/devices.js +0 -949
  42. package/dist/commands/devices.js.map +0 -1
  43. package/dist/commands/doctor.d.ts +0 -3
  44. package/dist/commands/doctor.js +0 -1016
  45. package/dist/commands/doctor.js.map +0 -1
  46. package/dist/commands/events.d.ts +0 -31
  47. package/dist/commands/events.js +0 -564
  48. package/dist/commands/events.js.map +0 -1
  49. package/dist/commands/expand.d.ts +0 -2
  50. package/dist/commands/expand.js +0 -131
  51. package/dist/commands/expand.js.map +0 -1
  52. package/dist/commands/explain.d.ts +0 -2
  53. package/dist/commands/explain.js +0 -140
  54. package/dist/commands/explain.js.map +0 -1
  55. package/dist/commands/health.d.ts +0 -8
  56. package/dist/commands/health.js +0 -114
  57. package/dist/commands/health.js.map +0 -1
  58. package/dist/commands/history.d.ts +0 -2
  59. package/dist/commands/history.js +0 -321
  60. package/dist/commands/history.js.map +0 -1
  61. package/dist/commands/identity.d.ts +0 -45
  62. package/dist/commands/identity.js +0 -60
  63. package/dist/commands/identity.js.map +0 -1
  64. package/dist/commands/install.d.ts +0 -20
  65. package/dist/commands/install.js +0 -247
  66. package/dist/commands/install.js.map +0 -1
  67. package/dist/commands/mcp.d.ts +0 -14
  68. package/dist/commands/mcp.js +0 -2018
  69. package/dist/commands/mcp.js.map +0 -1
  70. package/dist/commands/plan.d.ts +0 -51
  71. package/dist/commands/plan.js +0 -654
  72. package/dist/commands/plan.js.map +0 -1
  73. package/dist/commands/policy.d.ts +0 -24
  74. package/dist/commands/policy.js +0 -587
  75. package/dist/commands/policy.js.map +0 -1
  76. package/dist/commands/quota.d.ts +0 -2
  77. package/dist/commands/quota.js +0 -79
  78. package/dist/commands/quota.js.map +0 -1
  79. package/dist/commands/rules.d.ts +0 -2
  80. package/dist/commands/rules.js +0 -876
  81. package/dist/commands/rules.js.map +0 -1
  82. package/dist/commands/scenes.d.ts +0 -2
  83. package/dist/commands/scenes.js +0 -265
  84. package/dist/commands/scenes.js.map +0 -1
  85. package/dist/commands/schema.d.ts +0 -2
  86. package/dist/commands/schema.js +0 -185
  87. package/dist/commands/schema.js.map +0 -1
  88. package/dist/commands/status-sync.d.ts +0 -2
  89. package/dist/commands/status-sync.js +0 -132
  90. package/dist/commands/status-sync.js.map +0 -1
  91. package/dist/commands/uninstall.d.ts +0 -20
  92. package/dist/commands/uninstall.js +0 -238
  93. package/dist/commands/uninstall.js.map +0 -1
  94. package/dist/commands/upgrade-check.d.ts +0 -2
  95. package/dist/commands/upgrade-check.js +0 -107
  96. package/dist/commands/upgrade-check.js.map +0 -1
  97. package/dist/commands/watch.d.ts +0 -2
  98. package/dist/commands/watch.js +0 -195
  99. package/dist/commands/watch.js.map +0 -1
  100. package/dist/commands/webhook.d.ts +0 -2
  101. package/dist/commands/webhook.js +0 -183
  102. package/dist/commands/webhook.js.map +0 -1
  103. package/dist/config.d.ts +0 -57
  104. package/dist/config.js +0 -259
  105. package/dist/config.js.map +0 -1
  106. package/dist/credentials/backends/file.d.ts +0 -18
  107. package/dist/credentials/backends/file.js +0 -102
  108. package/dist/credentials/backends/file.js.map +0 -1
  109. package/dist/credentials/backends/linux.d.ts +0 -16
  110. package/dist/credentials/backends/linux.js +0 -130
  111. package/dist/credentials/backends/linux.js.map +0 -1
  112. package/dist/credentials/backends/macos.d.ts +0 -18
  113. package/dist/credentials/backends/macos.js +0 -130
  114. package/dist/credentials/backends/macos.js.map +0 -1
  115. package/dist/credentials/backends/windows.d.ts +0 -23
  116. package/dist/credentials/backends/windows.js +0 -216
  117. package/dist/credentials/backends/windows.js.map +0 -1
  118. package/dist/credentials/keychain.d.ts +0 -83
  119. package/dist/credentials/keychain.js +0 -89
  120. package/dist/credentials/keychain.js.map +0 -1
  121. package/dist/credentials/prime.d.ts +0 -32
  122. package/dist/credentials/prime.js +0 -53
  123. package/dist/credentials/prime.js.map +0 -1
  124. package/dist/devices/cache.d.ts +0 -79
  125. package/dist/devices/cache.js +0 -294
  126. package/dist/devices/cache.js.map +0 -1
  127. package/dist/devices/catalog.d.ts +0 -138
  128. package/dist/devices/catalog.js +0 -768
  129. package/dist/devices/catalog.js.map +0 -1
  130. package/dist/devices/device-meta.d.ts +0 -15
  131. package/dist/devices/device-meta.js +0 -57
  132. package/dist/devices/device-meta.js.map +0 -1
  133. package/dist/devices/history-agg.d.ts +0 -37
  134. package/dist/devices/history-agg.js +0 -139
  135. package/dist/devices/history-agg.js.map +0 -1
  136. package/dist/devices/history-query.d.ts +0 -45
  137. package/dist/devices/history-query.js +0 -182
  138. package/dist/devices/history-query.js.map +0 -1
  139. package/dist/devices/param-validator.d.ts +0 -40
  140. package/dist/devices/param-validator.js +0 -434
  141. package/dist/devices/param-validator.js.map +0 -1
  142. package/dist/devices/resources.d.ts +0 -74
  143. package/dist/devices/resources.js +0 -271
  144. package/dist/devices/resources.js.map +0 -1
  145. package/dist/index.d.ts +0 -1
  146. package/dist/index.js.map +0 -1
  147. package/dist/install/default-steps.d.ts +0 -66
  148. package/dist/install/default-steps.js +0 -258
  149. package/dist/install/default-steps.js.map +0 -1
  150. package/dist/install/preflight.d.ts +0 -60
  151. package/dist/install/preflight.js +0 -213
  152. package/dist/install/preflight.js.map +0 -1
  153. package/dist/install/steps.d.ts +0 -61
  154. package/dist/install/steps.js +0 -68
  155. package/dist/install/steps.js.map +0 -1
  156. package/dist/lib/command-keywords.d.ts +0 -5
  157. package/dist/lib/command-keywords.js +0 -18
  158. package/dist/lib/command-keywords.js.map +0 -1
  159. package/dist/lib/daemon-state.d.ts +0 -24
  160. package/dist/lib/daemon-state.js +0 -47
  161. package/dist/lib/daemon-state.js.map +0 -1
  162. package/dist/lib/destructive-mode.d.ts +0 -2
  163. package/dist/lib/destructive-mode.js +0 -13
  164. package/dist/lib/destructive-mode.js.map +0 -1
  165. package/dist/lib/devices.d.ts +0 -151
  166. package/dist/lib/devices.js +0 -383
  167. package/dist/lib/devices.js.map +0 -1
  168. package/dist/lib/idempotency.d.ts +0 -46
  169. package/dist/lib/idempotency.js +0 -107
  170. package/dist/lib/idempotency.js.map +0 -1
  171. package/dist/lib/plan-store.d.ts +0 -19
  172. package/dist/lib/plan-store.js +0 -69
  173. package/dist/lib/plan-store.js.map +0 -1
  174. package/dist/lib/request-context.d.ts +0 -7
  175. package/dist/lib/request-context.js +0 -13
  176. package/dist/lib/request-context.js.map +0 -1
  177. package/dist/lib/scenes.d.ts +0 -7
  178. package/dist/lib/scenes.js +0 -11
  179. package/dist/lib/scenes.js.map +0 -1
  180. package/dist/logger.d.ts +0 -4
  181. package/dist/logger.js +0 -17
  182. package/dist/logger.js.map +0 -1
  183. package/dist/mcp/device-history.d.ts +0 -36
  184. package/dist/mcp/device-history.js +0 -146
  185. package/dist/mcp/device-history.js.map +0 -1
  186. package/dist/mcp/events-subscription.d.ts +0 -45
  187. package/dist/mcp/events-subscription.js +0 -214
  188. package/dist/mcp/events-subscription.js.map +0 -1
  189. package/dist/mqtt/client.d.ts +0 -25
  190. package/dist/mqtt/client.js +0 -181
  191. package/dist/mqtt/client.js.map +0 -1
  192. package/dist/mqtt/credential.d.ts +0 -16
  193. package/dist/mqtt/credential.js +0 -31
  194. package/dist/mqtt/credential.js.map +0 -1
  195. package/dist/policy/add-rule.d.ts +0 -21
  196. package/dist/policy/add-rule.js +0 -125
  197. package/dist/policy/add-rule.js.map +0 -1
  198. package/dist/policy/diff.d.ts +0 -21
  199. package/dist/policy/diff.js +0 -92
  200. package/dist/policy/diff.js.map +0 -1
  201. package/dist/policy/format.d.ts +0 -6
  202. package/dist/policy/format.js +0 -58
  203. package/dist/policy/format.js.map +0 -1
  204. package/dist/policy/load.d.ts +0 -32
  205. package/dist/policy/load.js +0 -62
  206. package/dist/policy/load.js.map +0 -1
  207. package/dist/policy/migrate.d.ts +0 -21
  208. package/dist/policy/migrate.js +0 -68
  209. package/dist/policy/migrate.js.map +0 -1
  210. package/dist/policy/schema.d.ts +0 -5
  211. package/dist/policy/schema.js +0 -19
  212. package/dist/policy/schema.js.map +0 -1
  213. package/dist/policy/validate.d.ts +0 -19
  214. package/dist/policy/validate.js +0 -263
  215. package/dist/policy/validate.js.map +0 -1
  216. package/dist/rules/action.d.ts +0 -65
  217. package/dist/rules/action.js +0 -217
  218. package/dist/rules/action.js.map +0 -1
  219. package/dist/rules/audit-query.d.ts +0 -51
  220. package/dist/rules/audit-query.js +0 -90
  221. package/dist/rules/audit-query.js.map +0 -1
  222. package/dist/rules/conflict-analyzer.d.ts +0 -57
  223. package/dist/rules/conflict-analyzer.js +0 -215
  224. package/dist/rules/conflict-analyzer.js.map +0 -1
  225. package/dist/rules/cron-scheduler.d.ts +0 -62
  226. package/dist/rules/cron-scheduler.js +0 -187
  227. package/dist/rules/cron-scheduler.js.map +0 -1
  228. package/dist/rules/destructive.d.ts +0 -20
  229. package/dist/rules/destructive.js +0 -53
  230. package/dist/rules/destructive.js.map +0 -1
  231. package/dist/rules/engine.d.ts +0 -193
  232. package/dist/rules/engine.js +0 -758
  233. package/dist/rules/engine.js.map +0 -1
  234. package/dist/rules/matcher.d.ts +0 -56
  235. package/dist/rules/matcher.js +0 -231
  236. package/dist/rules/matcher.js.map +0 -1
  237. package/dist/rules/pid-file.d.ts +0 -43
  238. package/dist/rules/pid-file.js +0 -96
  239. package/dist/rules/pid-file.js.map +0 -1
  240. package/dist/rules/quiet-hours.d.ts +0 -26
  241. package/dist/rules/quiet-hours.js +0 -46
  242. package/dist/rules/quiet-hours.js.map +0 -1
  243. package/dist/rules/suggest.d.ts +0 -20
  244. package/dist/rules/suggest.js +0 -96
  245. package/dist/rules/suggest.js.map +0 -1
  246. package/dist/rules/throttle.d.ts +0 -61
  247. package/dist/rules/throttle.js +0 -117
  248. package/dist/rules/throttle.js.map +0 -1
  249. package/dist/rules/types.d.ts +0 -117
  250. package/dist/rules/types.js +0 -35
  251. package/dist/rules/types.js.map +0 -1
  252. package/dist/rules/webhook-listener.d.ts +0 -63
  253. package/dist/rules/webhook-listener.js +0 -224
  254. package/dist/rules/webhook-listener.js.map +0 -1
  255. package/dist/rules/webhook-token.d.ts +0 -50
  256. package/dist/rules/webhook-token.js +0 -91
  257. package/dist/rules/webhook-token.js.map +0 -1
  258. package/dist/schema/field-aliases.d.ts +0 -34
  259. package/dist/schema/field-aliases.js +0 -132
  260. package/dist/schema/field-aliases.js.map +0 -1
  261. package/dist/sinks/dispatcher.d.ts +0 -7
  262. package/dist/sinks/dispatcher.js +0 -13
  263. package/dist/sinks/dispatcher.js.map +0 -1
  264. package/dist/sinks/file.d.ts +0 -6
  265. package/dist/sinks/file.js +0 -20
  266. package/dist/sinks/file.js.map +0 -1
  267. package/dist/sinks/format.d.ts +0 -20
  268. package/dist/sinks/format.js +0 -57
  269. package/dist/sinks/format.js.map +0 -1
  270. package/dist/sinks/homeassistant.d.ts +0 -18
  271. package/dist/sinks/homeassistant.js +0 -45
  272. package/dist/sinks/homeassistant.js.map +0 -1
  273. package/dist/sinks/openclaw.d.ts +0 -13
  274. package/dist/sinks/openclaw.js +0 -34
  275. package/dist/sinks/openclaw.js.map +0 -1
  276. package/dist/sinks/stdout.d.ts +0 -4
  277. package/dist/sinks/stdout.js +0 -6
  278. package/dist/sinks/stdout.js.map +0 -1
  279. package/dist/sinks/telegram.d.ts +0 -11
  280. package/dist/sinks/telegram.js +0 -29
  281. package/dist/sinks/telegram.js.map +0 -1
  282. package/dist/sinks/types.d.ts +0 -13
  283. package/dist/sinks/types.js +0 -2
  284. package/dist/sinks/types.js.map +0 -1
  285. package/dist/sinks/webhook.d.ts +0 -6
  286. package/dist/sinks/webhook.js +0 -23
  287. package/dist/sinks/webhook.js.map +0 -1
  288. package/dist/status-sync/manager.d.ts +0 -48
  289. package/dist/status-sync/manager.js +0 -269
  290. package/dist/status-sync/manager.js.map +0 -1
  291. package/dist/utils/arg-parsers.d.ts +0 -16
  292. package/dist/utils/arg-parsers.js +0 -67
  293. package/dist/utils/arg-parsers.js.map +0 -1
  294. package/dist/utils/audit.d.ts +0 -69
  295. package/dist/utils/audit.js +0 -122
  296. package/dist/utils/audit.js.map +0 -1
  297. package/dist/utils/filter.d.ts +0 -81
  298. package/dist/utils/filter.js +0 -190
  299. package/dist/utils/filter.js.map +0 -1
  300. package/dist/utils/flags.d.ts +0 -72
  301. package/dist/utils/flags.js +0 -187
  302. package/dist/utils/flags.js.map +0 -1
  303. package/dist/utils/format.d.ts +0 -9
  304. package/dist/utils/format.js +0 -118
  305. package/dist/utils/format.js.map +0 -1
  306. package/dist/utils/health.d.ts +0 -48
  307. package/dist/utils/health.js +0 -102
  308. package/dist/utils/health.js.map +0 -1
  309. package/dist/utils/help-json.d.ts +0 -39
  310. package/dist/utils/help-json.js +0 -55
  311. package/dist/utils/help-json.js.map +0 -1
  312. package/dist/utils/name-resolver.d.ts +0 -26
  313. package/dist/utils/name-resolver.js +0 -138
  314. package/dist/utils/name-resolver.js.map +0 -1
  315. package/dist/utils/output.d.ts +0 -73
  316. package/dist/utils/output.js +0 -405
  317. package/dist/utils/output.js.map +0 -1
  318. package/dist/utils/quota.d.ts +0 -61
  319. package/dist/utils/quota.js +0 -228
  320. package/dist/utils/quota.js.map +0 -1
  321. package/dist/utils/redact.d.ts +0 -23
  322. package/dist/utils/redact.js +0 -69
  323. package/dist/utils/redact.js.map +0 -1
  324. package/dist/utils/retry.d.ts +0 -72
  325. package/dist/utils/retry.js +0 -141
  326. package/dist/utils/retry.js.map +0 -1
  327. package/dist/utils/string.d.ts +0 -2
  328. package/dist/utils/string.js +0 -23
  329. package/dist/utils/string.js.map +0 -1
  330. package/dist/version.d.ts +0 -2
  331. package/dist/version.js +0 -5
  332. package/dist/version.js.map +0 -1
@@ -1,2018 +0,0 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
- import { z } from 'zod';
5
- import { intArg, stringArg } from '../utils/arg-parsers.js';
6
- import { handleError, buildErrorPayload, exitWithError } from '../utils/output.js';
7
- import { VERSION } from '../version.js';
8
- import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
9
- import { fetchScenes, executeScene } from '../lib/scenes.js';
10
- import { findCatalogEntry, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
11
- import { getCachedDevice } from '../devices/cache.js';
12
- import { validateParameter } from '../devices/param-validator.js';
13
- import { EventSubscriptionManager } from '../mcp/events-subscription.js';
14
- import { deviceHistoryStore } from '../mcp/device-history.js';
15
- import { queryDeviceHistory } from '../devices/history-query.js';
16
- import { aggregateDeviceHistory, ALL_AGG_FNS, MAX_SAMPLE_CAP, } from '../devices/history-agg.js';
17
- import { todayUsage } from '../utils/quota.js';
18
- import { describeCache } from '../devices/cache.js';
19
- import { withRequestContext } from '../lib/request-context.js';
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 { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
29
- import { writeFileSync } from 'node:fs';
30
- import { readAudit } from '../utils/audit.js';
31
- import { parseDurationToMs } from '../utils/flags.js';
32
- import { resolveDeviceId } from '../utils/name-resolver.js';
33
- import { validatePlan } from './plan.js';
34
- import { parse as yamlParse } from 'yaml';
35
- import { diffPolicyValues } from '../policy/diff.js';
36
- const LATEST_SUPPORTED_VERSION = SUPPORTED_POLICY_SCHEMA_VERSIONS[SUPPORTED_POLICY_SCHEMA_VERSIONS.length - 1];
37
- import { fileURLToPath } from 'node:url';
38
- import { dirname as pathDirname, join as pathJoin } from 'node:path';
39
- import os from 'node:os';
40
- import fs from 'node:fs';
41
- function mcpError(kind, code, message, options) {
42
- const obj = { code, kind, message };
43
- if (options?.hint)
44
- obj.hint = options.hint;
45
- if (options?.retryable)
46
- obj.retryable = true;
47
- if (options?.context)
48
- obj.context = options.context;
49
- if (options?.subKind !== undefined)
50
- obj.subKind = options.subKind;
51
- if (options?.errorClass !== undefined)
52
- obj.errorClass = options.errorClass;
53
- if (options?.transient !== undefined)
54
- obj.transient = options.transient;
55
- if (options?.retryAfterMs !== undefined)
56
- obj.retryAfterMs = options.retryAfterMs;
57
- return {
58
- isError: true,
59
- content: [{ type: 'text', text: JSON.stringify({ error: obj }, null, 2) }],
60
- structuredContent: { error: obj },
61
- };
62
- }
63
- /**
64
- * Convert any thrown error into a structured MCP tool-error response,
65
- * preserving all ErrorPayload fields (subKind, transient, hint, etc.).
66
- */
67
- function apiErrorToMcpError(err) {
68
- const payload = buildErrorPayload(err);
69
- return mcpError(payload.kind, payload.code, payload.message, {
70
- hint: payload.hint,
71
- retryable: payload.retryable,
72
- context: payload.context,
73
- subKind: payload.subKind,
74
- errorClass: payload.errorClass,
75
- transient: payload.transient,
76
- retryAfterMs: payload.retryAfterMs,
77
- });
78
- }
79
- const DEFAULT_AUDIT_LOG_FILE = pathJoin(os.homedir(), '.switchbot', 'audit.log');
80
- function resolveAuditRange(opts) {
81
- if (opts.since && (opts.from || opts.to)) {
82
- throw new Error('--since is mutually exclusive with --from/--to.');
83
- }
84
- if (opts.since) {
85
- const dur = parseDurationToMs(opts.since);
86
- if (dur === null) {
87
- throw new Error(`Invalid --since value "${opts.since}". Expected e.g. "30s", "15m", "1h", "7d".`);
88
- }
89
- return { fromMs: Date.now() - dur, toMs: Number.POSITIVE_INFINITY };
90
- }
91
- let fromMs = Number.NEGATIVE_INFINITY;
92
- let toMs = Number.POSITIVE_INFINITY;
93
- if (opts.from) {
94
- const parsed = Date.parse(opts.from);
95
- if (!Number.isFinite(parsed)) {
96
- throw new Error(`Invalid --from value "${opts.from}". Expected ISO-8601 timestamp.`);
97
- }
98
- fromMs = parsed;
99
- }
100
- if (opts.to) {
101
- const parsed = Date.parse(opts.to);
102
- if (!Number.isFinite(parsed)) {
103
- throw new Error(`Invalid --to value "${opts.to}". Expected ISO-8601 timestamp.`);
104
- }
105
- toMs = parsed;
106
- }
107
- if (fromMs > toMs) {
108
- throw new Error('--from must be <= --to.');
109
- }
110
- return { fromMs, toMs };
111
- }
112
- function filterAuditEntries(entries, opts) {
113
- const { fromMs, toMs } = resolveAuditRange(opts);
114
- return entries.filter((entry) => {
115
- const tMs = Date.parse(entry.t);
116
- if (!Number.isFinite(tMs))
117
- return false;
118
- if (tMs < fromMs || tMs > toMs)
119
- return false;
120
- if (opts.kinds && opts.kinds.length > 0 && !opts.kinds.includes(entry.kind))
121
- return false;
122
- if (opts.deviceId && entry.deviceId !== opts.deviceId)
123
- return false;
124
- if (opts.ruleName && entry.rule?.name !== opts.ruleName)
125
- return false;
126
- if (opts.results && opts.results.length > 0) {
127
- if (!entry.result || !opts.results.includes(entry.result))
128
- return false;
129
- }
130
- return true;
131
- });
132
- }
133
- function topNFromMap(counts, n) {
134
- return [...counts.entries()]
135
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
136
- .slice(0, n)
137
- .map(([key, count]) => ({ key, count }));
138
- }
139
- /**
140
- * Compute per-action risk metadata from the device catalog.
141
- * `idempotencyHint` is sourced from CommandSpec.idempotent when available;
142
- * falls back to "safe" only for unknown commands on non-destructive paths.
143
- */
144
- function buildRiskProfile(typeName, command, commandType, isDestructive) {
145
- // Look up the catalog spec to get the authoritative idempotent flag.
146
- let idempotencyHint = isDestructive ? 'non-idempotent' : 'safe';
147
- if (typeName && commandType === 'command') {
148
- const entry = findCatalogEntry(typeName);
149
- const entries = Array.isArray(entry) ? entry : entry ? [entry] : [];
150
- for (const e of entries) {
151
- const spec = e.commands.find((c) => c.command === command);
152
- if (spec !== undefined) {
153
- idempotencyHint = spec.idempotent === true ? 'safe' : 'non-idempotent';
154
- break;
155
- }
156
- }
157
- }
158
- return {
159
- riskLevel: isDestructive ? 'high' : commandType === 'command' ? 'medium' : 'low',
160
- requiresConfirmation: isDestructive,
161
- supportsDryRun: true,
162
- idempotencyHint,
163
- recommendedMode: isDestructive ? 'review-before-execute' : 'plan',
164
- };
165
- }
166
- export function createSwitchBotMcpServer(options) {
167
- const eventManager = options?.eventManager;
168
- const server = new McpServer({
169
- name: 'switchbot',
170
- version: VERSION,
171
- }, {
172
- capabilities: { tools: {}, resources: {} },
173
- instructions: `SwitchBot is an IoT smart home brand by Wonderlabs, Inc. This MCP server controls physical devices \
174
- (Bot, Curtain, Smart Lock, Color Bulb, Meter, Plug, Robot Vacuum, etc.) and IR remotes \
175
- (TV, AC, Set Top Box, etc.) via the SwitchBot Cloud API v1.1.
176
-
177
- Device categories:
178
- - physical: Wi-Fi/BLE devices; BLE-only ones require a Hub (check enableCloudService)
179
- - ir: IR remotes learned by a Hub; no status channel, commands only
180
-
181
- Key constraints:
182
- - API quota: 10,000 requests/day per account — use cache, avoid polling
183
- - Destructive commands (unlock, garage open, keypad createKey/deleteKey) require confirm:true
184
- - Devices without enableCloudService cannot receive commands via API
185
-
186
- Recommended bootstrap sequence:
187
- 1. list_devices → get deviceIds and categories
188
- 2. search_catalog or describe_device → confirm supported commands offline/online
189
- 3. send_command (with confirm:true for destructive commands)
190
-
191
- API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
192
- });
193
- // ---- list_devices ---------------------------------------------------------
194
- server.registerTool('list_devices', {
195
- title: 'List all devices on the account',
196
- 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.',
197
- _meta: { agentSafetyTier: 'read' },
198
- inputSchema: z.object({}).strict(),
199
- outputSchema: {
200
- deviceList: z.array(z.object({
201
- deviceId: z.string(),
202
- deviceName: z.string(),
203
- deviceType: z.string().optional(),
204
- enableCloudService: z.boolean(),
205
- hubDeviceId: z.string(),
206
- roomID: z.string().optional(),
207
- roomName: z.string().nullable().optional(),
208
- familyName: z.string().optional(),
209
- controlType: z.string().optional(),
210
- }).passthrough()).describe('Physical SwitchBot devices'),
211
- infraredRemoteList: z.array(z.object({
212
- deviceId: z.string(),
213
- deviceName: z.string(),
214
- remoteType: z.string(),
215
- hubDeviceId: z.string(),
216
- controlType: z.string().optional(),
217
- }).passthrough()).describe('IR remote devices'),
218
- },
219
- }, async () => {
220
- const body = await fetchDeviceList();
221
- return {
222
- content: [{ type: 'text', text: JSON.stringify(body, null, 2) }],
223
- structuredContent: {
224
- deviceList: body.deviceList.map(toMcpDeviceListShape),
225
- infraredRemoteList: body.infraredRemoteList.map(toMcpIrDeviceShape),
226
- },
227
- };
228
- });
229
- // ---- get_device_status ----------------------------------------------------
230
- server.registerTool('get_device_status', {
231
- title: 'Get live status for a device',
232
- description: 'Query the real-time status payload for a physical device. IR remotes have no status channel and will error.',
233
- _meta: { agentSafetyTier: 'read' },
234
- inputSchema: z.object({
235
- deviceId: z.string().describe('Device ID from list_devices'),
236
- }).strict(),
237
- outputSchema: {
238
- status: z.object({
239
- deviceId: z.string().optional(),
240
- deviceType: z.string().optional(),
241
- hubDeviceId: z.string().optional(),
242
- connectionStatus: z.string().optional(),
243
- }).passthrough().describe('Live device status (deviceId + deviceType + device-specific fields)'),
244
- },
245
- }, async ({ deviceId }) => {
246
- const body = await fetchDeviceStatus(deviceId);
247
- return {
248
- content: [{ type: 'text', text: JSON.stringify(body, null, 2) }],
249
- structuredContent: { status: body },
250
- };
251
- });
252
- // ---- get_device_history ----------------------------------------------------
253
- server.registerTool('get_device_history', {
254
- title: 'Get locally-persisted device state history',
255
- description: 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' +
256
- 'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' +
257
- 'Omit deviceId to list all devices with stored history.',
258
- _meta: { agentSafetyTier: 'read' },
259
- inputSchema: z.object({
260
- deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'),
261
- limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'),
262
- }).strict(),
263
- outputSchema: {
264
- deviceId: z.string().optional(),
265
- latest: z.unknown().optional(),
266
- history: z.array(z.unknown()).optional(),
267
- devices: z.array(z.object({ deviceId: z.string(), latest: z.unknown() })).optional(),
268
- },
269
- }, async ({ deviceId, limit }) => {
270
- if (deviceId) {
271
- const latest = deviceHistoryStore.getLatest(deviceId);
272
- const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
273
- const result = { deviceId, latest, history };
274
- return {
275
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
276
- structuredContent: result,
277
- };
278
- }
279
- const ids = deviceHistoryStore.listDevices();
280
- const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) }));
281
- const result = { devices };
282
- return {
283
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
284
- structuredContent: result,
285
- };
286
- });
287
- // ---- query_device_history --------------------------------------------------
288
- server.registerTool('query_device_history', {
289
- title: 'Query time-ranged device history',
290
- description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) ' +
291
- 'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' +
292
- 'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
293
- _meta: { agentSafetyTier: 'read' },
294
- inputSchema: z.object({
295
- deviceId: z.string().describe('Device ID to query'),
296
- since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
297
- from: z.string().optional().describe('Range start (ISO-8601).'),
298
- to: z.string().optional().describe('Range end (ISO-8601).'),
299
- fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'),
300
- limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'),
301
- }).strict(),
302
- outputSchema: {
303
- deviceId: z.string(),
304
- count: z.number().int(),
305
- records: z.array(z.object({
306
- t: z.string(),
307
- topic: z.string(),
308
- deviceType: z.string().optional(),
309
- payload: z.unknown(),
310
- })),
311
- },
312
- }, async ({ deviceId, since, from, to, fields, limit }) => {
313
- if (since && (from || to)) {
314
- return mcpError('usage', 2, '--since is mutually exclusive with --from/--to.');
315
- }
316
- try {
317
- const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
318
- const result = { deviceId, count: records.length, records };
319
- return {
320
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
321
- structuredContent: result,
322
- };
323
- }
324
- catch (err) {
325
- const msg = err instanceof Error ? err.message : 'history query failed';
326
- return mcpError('usage', 2, msg);
327
- }
328
- });
329
- // ---- send_command ---------------------------------------------------------
330
- server.registerTool('send_command', {
331
- title: 'Send a control command to a device',
332
- 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.',
333
- _meta: { agentSafetyTier: 'action' },
334
- inputSchema: z.object({
335
- deviceId: z.string().describe('Device ID from list_devices'),
336
- command: z.string().describe('Command name, case-sensitive (e.g. turnOn, setColor, unlock)'),
337
- parameter: z
338
- .union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.unknown()), z.array(z.unknown())])
339
- .optional()
340
- .describe('Command parameter. Omit for no-arg commands.'),
341
- commandType: z
342
- .enum(['command', 'customize'])
343
- .optional()
344
- .default('command')
345
- .describe('"command" for built-in commands; "customize" for user-defined IR buttons'),
346
- confirm: z
347
- .boolean()
348
- .optional()
349
- .default(false)
350
- .describe('Required true for destructive commands (unlock, garage open, createKey, ...)'),
351
- idempotencyKey: z
352
- .string()
353
- .optional()
354
- .describe('Deduplication key — 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.'),
355
- dryRun: z
356
- .boolean()
357
- .optional()
358
- .describe('When true, do not call the API — return { ok:true, dryRun:true, wouldSend:{...} } instead.'),
359
- }).strict(),
360
- outputSchema: {
361
- ok: z.literal(true),
362
- command: z.string().optional(),
363
- deviceId: z.string().optional(),
364
- result: z.unknown().optional().describe('API response body from SwitchBot (absent on dryRun)'),
365
- riskProfile: z
366
- .object({
367
- riskLevel: z.enum(['high', 'medium', 'low']),
368
- requiresConfirmation: z.boolean(),
369
- supportsDryRun: z.literal(true),
370
- idempotencyHint: z.enum(['safe', 'non-idempotent']),
371
- recommendedMode: z.enum(['review-before-execute', 'plan', 'direct']),
372
- })
373
- .optional()
374
- .describe('Device+command-specific risk metadata. riskLevel:"high" means confirm:true was required. Always present on dryRun responses so agents can preview risk before committing.'),
375
- verification: z
376
- .object({
377
- verifiable: z.boolean(),
378
- reason: z.string(),
379
- suggestedFollowup: z.string(),
380
- })
381
- .optional()
382
- .describe('Present when the target is an IR device. IR is unidirectional — agents should treat the success as "signal sent" not "state changed".'),
383
- dryRun: z.literal(true).optional().describe('Present when dryRun:true was requested'),
384
- wouldSend: z.object({
385
- deviceId: z.string(),
386
- command: z.string(),
387
- parameter: z.unknown(),
388
- commandType: z.string(),
389
- }).optional().describe('The request shape that would have been POSTed (present when dryRun:true)'),
390
- },
391
- }, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
392
- const effectiveType = commandType ?? 'command';
393
- let effectiveCommand = command;
394
- let effectiveParameter = parameter;
395
- // stringifiedParam mirrors the CLI form that validateCommand /
396
- // validateParameter expect — B-1 runs on the string representation.
397
- const stringifiedParam = parameter === undefined ? undefined : typeof parameter === 'string' ? parameter : JSON.stringify(parameter);
398
- // dryRun early-return — no API call. We still preflight the deviceId
399
- // against the local cache so fabricated IDs don't silently pass
400
- // validation (bug #SYS-3). Dry-run is meant to catch bad inputs; a
401
- // dry-run that accepts anything is worse than no dry-run at all.
402
- if (dryRun) {
403
- const cached = getCachedDevice(deviceId);
404
- if (!cached) {
405
- return mcpError('usage', 2, `Device "${deviceId}" not found in local cache.`, {
406
- subKind: 'device-not-found',
407
- hint: "Run 'list_devices' first to warm the cache, then retry with dryRun:true.",
408
- context: { deviceId },
409
- });
410
- }
411
- const dryValidation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
412
- if (!dryValidation.ok) {
413
- return mcpError('usage', 2, dryValidation.error.message, {
414
- hint: dryValidation.error.hint,
415
- context: {
416
- validationKind: dryValidation.error.kind,
417
- deviceType: cached.type,
418
- command: effectiveCommand,
419
- },
420
- });
421
- }
422
- if (dryValidation.normalized) {
423
- effectiveCommand = dryValidation.normalized;
424
- }
425
- // R-2: run B-1 param validation in dry-run too, so dry-run doesn't
426
- // falsely accept inputs the live API would reject.
427
- if (effectiveType !== 'customize') {
428
- const pv = validateParameter(cached.type, effectiveCommand, stringifiedParam);
429
- if (!pv.ok) {
430
- return mcpError('usage', 2, pv.error, {
431
- hint: 'Dry-run rejected the parameter client-side; the API would reject it too.',
432
- context: { deviceType: cached.type, command: effectiveCommand, parameter: stringifiedParam },
433
- });
434
- }
435
- if (pv.normalized !== undefined) {
436
- effectiveParameter = pv.normalized;
437
- }
438
- }
439
- const wouldSend = {
440
- deviceId,
441
- command: effectiveCommand,
442
- parameter: effectiveParameter ?? 'default',
443
- commandType: effectiveType,
444
- };
445
- const dryIsDestructive = isDestructiveCommand(cached.type, effectiveCommand, effectiveType);
446
- const dryRiskProfile = buildRiskProfile(cached.type, effectiveCommand, effectiveType, dryIsDestructive);
447
- const structured = { ok: true, dryRun: true, riskProfile: dryRiskProfile, wouldSend };
448
- return {
449
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
450
- structuredContent: structured,
451
- };
452
- }
453
- // Resolve the device's catalog type via cache or a fresh lookup so we
454
- // can evaluate destructive/validation without an extra round-trip if
455
- // the cache is warm.
456
- let typeName = getCachedDevice(deviceId)?.type;
457
- if (!typeName) {
458
- const body = await fetchDeviceList();
459
- const physical = body.deviceList.find((d) => d.deviceId === deviceId);
460
- const ir = body.infraredRemoteList.find((d) => d.deviceId === deviceId);
461
- if (!physical && !ir) {
462
- return mcpError('runtime', 152, `Device not found: ${deviceId}`, {
463
- hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).",
464
- });
465
- }
466
- typeName = physical ? physical.deviceType : ir.remoteType;
467
- }
468
- const destructive = isDestructiveCommand(typeName, effectiveCommand, effectiveType);
469
- if (destructive && !allowsDirectDestructiveExecution()) {
470
- const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
471
- return mcpError('guard', 3, `Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`, {
472
- hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
473
- context: {
474
- command: effectiveCommand,
475
- deviceType: typeName,
476
- directExecutionAllowed: false,
477
- requiredWorkflow: 'plan-approval',
478
- ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}),
479
- },
480
- });
481
- }
482
- if (destructive && !confirm) {
483
- const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
484
- const entry = typeName ? findCatalogEntry(typeName) : null;
485
- const spec = entry && !Array.isArray(entry)
486
- ? entry.commands.find((c) => c.command === effectiveCommand)
487
- : undefined;
488
- const hint = reason
489
- ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}`
490
- : 'Re-issue the call with confirm:true to proceed.';
491
- return mcpError('guard', 3, `Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`, {
492
- hint,
493
- context: {
494
- command: effectiveCommand,
495
- deviceType: typeName,
496
- description: spec?.description ?? null,
497
- ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}),
498
- },
499
- });
500
- }
501
- // validateCommand covers command existence + required/unexpected-parameter.
502
- // stringifiedParam was computed once at the top of the handler so dry-run
503
- // and live paths share the same shape.
504
- const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
505
- if (!validation.ok) {
506
- return mcpError('usage', 2, validation.error.message, {
507
- hint: validation.error.hint,
508
- context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand },
509
- });
510
- }
511
- if (validation.normalized) {
512
- effectiveCommand = validation.normalized;
513
- }
514
- // R-2: run B-1 client-side parameter validator (range/format checks).
515
- // Customize commands (user-defined IR buttons) opt out — the catalog
516
- // cannot know their expected shape.
517
- if (effectiveType !== 'customize') {
518
- const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
519
- if (!pv.ok) {
520
- return mcpError('usage', 2, pv.error, {
521
- context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
522
- });
523
- }
524
- if (pv.normalized !== undefined) {
525
- effectiveParameter = pv.normalized;
526
- }
527
- }
528
- let result;
529
- try {
530
- result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, undefined, {
531
- idempotencyKey,
532
- });
533
- }
534
- catch (err) {
535
- if (err instanceof Error && err.name === 'IdempotencyConflictError') {
536
- return mcpError('guard', 2, err.message, {
537
- hint: 'Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).',
538
- context: {
539
- existingShape: err.existingShape,
540
- newShape: err.newShape,
541
- },
542
- });
543
- }
544
- return apiErrorToMcpError(err);
545
- }
546
- const isIr = getCachedDevice(deviceId)?.category === 'ir';
547
- const liveIsDestructive = destructive;
548
- const riskProfile = buildRiskProfile(typeName, effectiveCommand, effectiveType, liveIsDestructive);
549
- const structured = { ok: true, command: effectiveCommand, deviceId, result, riskProfile };
550
- if (isIr) {
551
- structured.verification = {
552
- verifiable: false,
553
- reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
554
- suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
555
- };
556
- }
557
- return {
558
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
559
- structuredContent: structured,
560
- };
561
- });
562
- // ---- run_scene ------------------------------------------------------------
563
- server.registerTool('run_scene', {
564
- title: 'Execute a manual scene',
565
- description: 'Execute a manual SwitchBot scene by its sceneId (from list_scenes).',
566
- _meta: { agentSafetyTier: 'action' },
567
- inputSchema: z.object({
568
- sceneId: z.string().describe('Scene ID from list_scenes'),
569
- dryRun: z
570
- .boolean()
571
- .optional()
572
- .describe('When true, do not call the API — return { ok:true, dryRun:true, wouldSend:{...} } instead.'),
573
- }).strict(),
574
- outputSchema: {
575
- ok: z.literal(true),
576
- sceneId: z.string().optional(),
577
- dryRun: z.literal(true).optional().describe('Present when dryRun:true was requested'),
578
- wouldSend: z.object({
579
- sceneId: z.string(),
580
- }).optional().describe('The request shape that would have been POSTed (present when dryRun:true)'),
581
- },
582
- }, async ({ sceneId, dryRun }) => {
583
- if (dryRun) {
584
- let scenes = [];
585
- try {
586
- scenes = await fetchScenes();
587
- }
588
- catch {
589
- // network failure — degrade gracefully, skip validation
590
- }
591
- const found = scenes.find((s) => s.sceneId === sceneId);
592
- if (scenes.length > 0 && !found) {
593
- return mcpError('usage', 2, `Scene not found: ${sceneId}`, {
594
- subKind: 'scene-not-found',
595
- hint: "Check the sceneId with 'list_scenes' (IDs are case-sensitive).",
596
- context: { sceneId, candidates: scenes.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })).slice(0, 5) },
597
- });
598
- }
599
- const wouldSend = { sceneId, sceneName: found?.sceneName ?? null };
600
- const structured = { ok: true, dryRun: true, wouldSend };
601
- return {
602
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
603
- structuredContent: structured,
604
- };
605
- }
606
- try {
607
- await executeScene(sceneId);
608
- }
609
- catch (err) {
610
- return apiErrorToMcpError(err);
611
- }
612
- const structured = { ok: true, sceneId };
613
- return {
614
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
615
- structuredContent: structured,
616
- };
617
- });
618
- // ---- list_scenes (companion to run_scene) ---------------------------------
619
- server.registerTool('list_scenes', {
620
- title: 'List all manual scenes',
621
- description: 'Fetch all manual scenes configured in the SwitchBot app.',
622
- _meta: { agentSafetyTier: 'read' },
623
- inputSchema: z.object({}).strict(),
624
- outputSchema: {
625
- scenes: z.array(z.object({ sceneId: z.string(), sceneName: z.string() })),
626
- },
627
- }, async () => {
628
- const scenes = await fetchScenes();
629
- return {
630
- content: [{ type: 'text', text: JSON.stringify(scenes, null, 2) }],
631
- structuredContent: { scenes },
632
- };
633
- });
634
- // ---- search_catalog -------------------------------------------------------
635
- server.registerTool('search_catalog', {
636
- title: 'Search the offline device catalog',
637
- 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.',
638
- _meta: { agentSafetyTier: 'read' },
639
- inputSchema: z.object({
640
- query: z.string().describe('Search query (matches type and aliases, case-insensitive). Must be non-empty; use list_catalog_types to enumerate instead.'),
641
- limit: z.number().int().min(1).max(100).optional().default(20).describe('Max entries returned (default 20)'),
642
- }).strict(),
643
- outputSchema: {
644
- results: z.array(z.object({
645
- type: z.string(),
646
- category: z.enum(['physical', 'ir']),
647
- commands: z.array(z.object({
648
- command: z.string(),
649
- parameter: z.string(),
650
- description: z.string(),
651
- commandType: z.enum(['command', 'customize']).optional(),
652
- idempotent: z.boolean().optional(),
653
- safetyTier: z.enum(['read', 'mutation', 'ir-fire-forget', 'destructive', 'maintenance']).optional(),
654
- safetyReason: z.string().optional(),
655
- }).passthrough()),
656
- aliases: z.array(z.string()).optional(),
657
- statusFields: z.array(z.string()).optional(),
658
- role: z.string().optional(),
659
- readOnly: z.boolean().optional(),
660
- }).passthrough()).describe('Matching catalog entries'),
661
- total: z.number().int().describe('Number of entries returned'),
662
- },
663
- }, async ({ query, limit }) => {
664
- if (query.trim() === '') {
665
- return mcpError('usage', 2, 'search_catalog requires a non-empty query.', {
666
- hint: "Pass a search term like 'Bot' or 'Hub', or call list_catalog_types to enumerate all types without a query.",
667
- });
668
- }
669
- const hits = searchCatalog(query, limit);
670
- const normalised = hits.map((e) => ({
671
- ...e,
672
- commands: e.commands.map((c) => {
673
- const tier = deriveSafetyTier(c, e);
674
- const reason = getCommandSafetyReason(c);
675
- return {
676
- ...c,
677
- safetyTier: tier,
678
- ...(reason ? { safetyReason: reason } : {}),
679
- };
680
- }),
681
- }));
682
- const structured = { results: normalised, total: normalised.length };
683
- return {
684
- content: [{ type: 'text', text: JSON.stringify(normalised, null, 2) }],
685
- structuredContent: structured,
686
- };
687
- });
688
- // ---- describe_device ------------------------------------------------------
689
- server.registerTool('describe_device', {
690
- title: 'Describe a specific device',
691
- description: 'Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.',
692
- _meta: { agentSafetyTier: 'read' },
693
- inputSchema: z.object({
694
- deviceId: z.string().describe('Device ID from list_devices'),
695
- live: z.boolean().optional().default(false).describe('Also fetch live /status values (costs 1 extra API call)'),
696
- }).strict(),
697
- outputSchema: {
698
- device: z.object({
699
- device: z.object({ deviceId: z.string(), deviceName: z.string() }).passthrough(),
700
- isPhysical: z.boolean(),
701
- typeName: z.string(),
702
- controlType: z.string().nullable(),
703
- source: z.enum(['catalog', 'live', 'catalog+live', 'none']),
704
- capabilities: z.unknown().nullable(),
705
- suggestedActions: z.array(z.object({
706
- command: z.string(),
707
- parameter: z.string().optional(),
708
- description: z.string(),
709
- })).optional(),
710
- inheritedLocation: z.object({
711
- family: z.string().optional(),
712
- room: z.string().optional(),
713
- }).optional(),
714
- }).passthrough().describe('Device metadata, catalog entry, capabilities, and optional live status'),
715
- },
716
- }, async ({ deviceId, live }) => {
717
- try {
718
- const result = await describeDevice(deviceId, { live });
719
- return {
720
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
721
- structuredContent: { device: toMcpDescribeShape(result) },
722
- };
723
- }
724
- catch (err) {
725
- if (err instanceof DeviceNotFoundError) {
726
- return mcpError('runtime', 152, err.message, {
727
- hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).",
728
- context: { deviceId },
729
- });
730
- }
731
- return apiErrorToMcpError(err);
732
- }
733
- });
734
- // ---- aggregate_device_history --------------------------------------------
735
- server.registerTool('aggregate_device_history', {
736
- title: 'Aggregate device history',
737
- description: 'Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL-recorded device history. Read-only; no network calls.',
738
- _meta: { agentSafetyTier: 'read' },
739
- inputSchema: z
740
- .object({
741
- deviceId: z.string().min(1).describe('Device ID to aggregate over (must exist in ~/.switchbot/device-history/).'),
742
- since: z
743
- .string()
744
- .optional()
745
- .describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
746
- from: z.string().optional().describe('Range start (ISO-8601). Requires `to`.'),
747
- to: z.string().optional().describe('Range end (ISO-8601). Requires `from`.'),
748
- metrics: z
749
- .array(z.string().min(1))
750
- .min(1)
751
- .describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'),
752
- aggs: z
753
- .array(z.enum(ALL_AGG_FNS))
754
- .optional()
755
- .describe('Aggregation functions to apply per metric (default: ["count","avg"]).'),
756
- bucket: z
757
- .string()
758
- .optional()
759
- .describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'),
760
- maxBucketSamples: z
761
- .number()
762
- .int()
763
- .positive()
764
- .max(MAX_SAMPLE_CAP)
765
- .optional()
766
- .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.`),
767
- })
768
- .strict(),
769
- outputSchema: {
770
- deviceId: z.string(),
771
- bucket: z.string().optional().describe('Bucket width echoed back when specified; omitted for single-bucket results.'),
772
- from: z.string().describe('Effective range start (ISO-8601).'),
773
- to: z.string().describe('Effective range end (ISO-8601).'),
774
- metrics: z.array(z.string()).describe('Metrics that were requested.'),
775
- aggs: z
776
- .array(z.enum(ALL_AGG_FNS))
777
- .describe('Aggregation functions that were applied.'),
778
- buckets: z
779
- .array(z.object({
780
- t: z.string().describe('Bucket start timestamp (ISO-8601).'),
781
- metrics: z
782
- .record(z.string(), z
783
- .object({
784
- count: z.number().optional(),
785
- min: z.number().optional(),
786
- max: z.number().optional(),
787
- avg: z.number().optional(),
788
- sum: z.number().optional(),
789
- p50: z.number().optional(),
790
- p95: z.number().optional(),
791
- })
792
- .describe('Per-aggregate function result for this metric in this bucket.'))
793
- .describe('Per-metric result keyed by metric name.'),
794
- }))
795
- .describe('Time-ordered buckets; empty when no records match.'),
796
- partial: z.boolean().describe('True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values.'),
797
- notes: z.array(z.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").'),
798
- },
799
- }, async (args) => {
800
- const opts = {
801
- since: args.since,
802
- from: args.from,
803
- to: args.to,
804
- metrics: args.metrics,
805
- aggs: args.aggs,
806
- bucket: args.bucket,
807
- maxBucketSamples: args.maxBucketSamples,
808
- };
809
- const res = await aggregateDeviceHistory(args.deviceId, opts);
810
- const structured = {
811
- deviceId: res.deviceId,
812
- from: res.from,
813
- to: res.to,
814
- metrics: res.metrics,
815
- aggs: res.aggs,
816
- buckets: res.buckets,
817
- partial: res.partial,
818
- notes: res.notes,
819
- };
820
- if (res.bucket !== undefined)
821
- structured.bucket = res.bucket;
822
- return {
823
- content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
824
- structuredContent: structured,
825
- };
826
- });
827
- // ---- account_overview ---------------------------------------------------
828
- server.registerTool('account_overview', {
829
- title: 'Bootstrap account overview',
830
- 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.',
831
- _meta: { agentSafetyTier: 'read' },
832
- inputSchema: z.object({}).strict(),
833
- outputSchema: {
834
- version: z.string(),
835
- schemaVersion: z.string(),
836
- devices: z.array(z.object({
837
- deviceId: z.string(),
838
- deviceName: z.string(),
839
- deviceType: z.string().optional(),
840
- }).passthrough()).describe('All physical devices'),
841
- infraredRemotes: z.array(z.object({
842
- deviceId: z.string(),
843
- deviceName: z.string(),
844
- remoteType: z.string(),
845
- }).passthrough()).describe('All IR remotes'),
846
- scenes: z.array(z.object({
847
- sceneId: z.string(),
848
- sceneName: z.string(),
849
- }).passthrough()).describe('All manual scenes'),
850
- quota: z.object({
851
- date: z.string(),
852
- total: z.number(),
853
- remaining: z.number(),
854
- endpoints: z.record(z.string(), z.number()).optional(),
855
- }).describe('Today\'s quota usage'),
856
- cache: z.object({
857
- list: z.object({
858
- path: z.string(),
859
- exists: z.boolean(),
860
- lastUpdated: z.string().optional(),
861
- ageMs: z.number().optional(),
862
- deviceCount: z.number().optional(),
863
- }),
864
- status: z.object({
865
- path: z.string(),
866
- exists: z.boolean(),
867
- entryCount: z.number(),
868
- oldestFetchedAt: z.string().optional(),
869
- newestFetchedAt: z.string().optional(),
870
- }),
871
- }).describe('Cache status'),
872
- mqtt: z.object({
873
- state: z.string(),
874
- subscribers: z.number(),
875
- }).optional().describe('MQTT connection state (present when REST credentials are configured; auto-provisioned via POST /v1.1/iot/credential)'),
876
- },
877
- }, async () => {
878
- const deviceList = await fetchDeviceList();
879
- const sceneList = await fetchScenes();
880
- const cacheInfo = describeCache();
881
- const quota = todayUsage();
882
- const overview = {
883
- version: VERSION,
884
- schemaVersion: '1.1',
885
- devices: deviceList.deviceList.map(toMcpDeviceListShape),
886
- infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
887
- scenes: sceneList.map((s) => ({
888
- sceneId: s.sceneId,
889
- sceneName: s.sceneName,
890
- })),
891
- quota: {
892
- date: quota.date,
893
- total: quota.total,
894
- remaining: quota.remaining,
895
- endpoints: quota.endpoints,
896
- },
897
- cache: {
898
- list: cacheInfo.list,
899
- status: cacheInfo.status,
900
- },
901
- ...(eventManager ? {
902
- mqtt: {
903
- state: eventManager.getState(),
904
- subscribers: eventManager.getSubscriberCount(),
905
- },
906
- } : {}),
907
- };
908
- return {
909
- content: [{
910
- type: 'text',
911
- text: JSON.stringify(overview, null, 2),
912
- }],
913
- structuredContent: overview,
914
- };
915
- });
916
- // ---- policy_validate -----------------------------------------------------
917
- server.registerTool('policy_validate', {
918
- title: 'Validate a policy.yaml file',
919
- description: 'Check a policy file against the embedded JSON Schema (supports v0.1 and v0.2). ' +
920
- 'Returns the validation result with per-error line/col and a hint. ' +
921
- 'When no path is given, reads the resolved default (${SWITCHBOT_POLICY_PATH} or ~/.config/openclaw/switchbot/policy.yaml). ' +
922
- 'Use before relying on aliases/quiet_hours/confirmations so the agent never acts on a broken policy.',
923
- _meta: { agentSafetyTier: 'read' },
924
- inputSchema: z.object({
925
- path: z.string().optional().describe('Optional policy file path; defaults to the resolved default path'),
926
- }).strict(),
927
- outputSchema: {
928
- policyPath: z.string(),
929
- schemaVersion: z.string(),
930
- present: z.boolean().describe('false when the file does not exist'),
931
- valid: z.boolean().nullable().describe('null when present=false'),
932
- errors: z.array(z.object({
933
- path: z.string(),
934
- line: z.number().optional(),
935
- col: z.number().optional(),
936
- keyword: z.string(),
937
- message: z.string(),
938
- hint: z.string().optional(),
939
- schemaPath: z.string(),
940
- })).describe('Empty when valid or when the file is missing'),
941
- },
942
- }, async ({ path: pathArg }) => {
943
- const policyPath = resolvePolicyPath({ flag: pathArg });
944
- try {
945
- const loaded = loadPolicyFile(policyPath);
946
- const result = validateLoadedPolicy(loaded);
947
- const structured = {
948
- policyPath: result.policyPath,
949
- schemaVersion: result.schemaVersion,
950
- present: true,
951
- valid: result.valid,
952
- errors: result.errors,
953
- };
954
- return {
955
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
956
- structuredContent: structured,
957
- };
958
- }
959
- catch (err) {
960
- if (err instanceof PolicyFileNotFoundError) {
961
- const structured = {
962
- policyPath,
963
- schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
964
- present: false,
965
- valid: null,
966
- errors: [],
967
- };
968
- return {
969
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
970
- structuredContent: structured,
971
- };
972
- }
973
- if (err instanceof PolicyYamlParseError) {
974
- const structured = {
975
- policyPath,
976
- schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
977
- present: true,
978
- valid: false,
979
- errors: err.yamlErrors.map((e) => ({
980
- path: '',
981
- line: e.line,
982
- col: e.col,
983
- keyword: 'yaml-parse',
984
- message: e.message,
985
- schemaPath: '',
986
- })),
987
- };
988
- return {
989
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
990
- structuredContent: structured,
991
- };
992
- }
993
- throw err;
994
- }
995
- });
996
- // ---- policy_new ----------------------------------------------------------
997
- server.registerTool('policy_new', {
998
- title: 'Scaffold a starter policy.yaml',
999
- description: 'Write a starter policy file to the resolved default path (or a given path). Refuses to overwrite unless force=true. ' +
1000
- 'This is a write action: the agent should only call it after confirming with the user.',
1001
- _meta: { agentSafetyTier: 'action' },
1002
- inputSchema: z.object({
1003
- path: z.string().optional().describe('Optional target path; defaults to the resolved default'),
1004
- force: z.boolean().optional().describe('When true, overwrite an existing file'),
1005
- }).strict(),
1006
- outputSchema: {
1007
- policyPath: z.string(),
1008
- schemaVersion: z.string(),
1009
- bytesWritten: z.number(),
1010
- overwritten: z.boolean(),
1011
- },
1012
- }, async ({ path: pathArg, force }) => {
1013
- const policyPath = resolvePolicyPath({ flag: pathArg });
1014
- const doForce = force === true;
1015
- if (fs.existsSync(policyPath) && !doForce) {
1016
- return mcpError('guard', 5, `refusing to overwrite existing policy at ${policyPath}`, {
1017
- hint: 'pass force=true to overwrite, or choose a different path',
1018
- context: { policyPath },
1019
- });
1020
- }
1021
- const templateUrl = new URL('../policy/examples/policy.example.yaml', import.meta.url);
1022
- const template = fs.readFileSync(fileURLToPath(templateUrl), 'utf-8');
1023
- fs.mkdirSync(pathDirname(policyPath), { recursive: true });
1024
- fs.writeFileSync(policyPath, template, { encoding: 'utf-8' });
1025
- const structured = {
1026
- policyPath,
1027
- schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
1028
- bytesWritten: Buffer.byteLength(template, 'utf-8'),
1029
- overwritten: doForce,
1030
- };
1031
- return {
1032
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1033
- structuredContent: structured,
1034
- };
1035
- });
1036
- // ---- policy_migrate ------------------------------------------------------
1037
- server.registerTool('policy_migrate', {
1038
- title: 'Migrate a policy file to the latest supported schema',
1039
- description: 'Upgrades the policy file\'s schema version in place while preserving comments. ' +
1040
- 'Safe by default: if the migrated document would fail schema validation, the file is NOT rewritten ' +
1041
- 'and the tool returns status="precheck-failed" with the list of errors. ' +
1042
- 'Pass dryRun=true to preview without touching the file. ' +
1043
- 'Currently the only supported upgrade path is v0.1 → v0.2.',
1044
- _meta: { agentSafetyTier: 'action' },
1045
- inputSchema: z.object({
1046
- path: z.string().optional().describe('Optional policy file path; defaults to the resolved default path'),
1047
- dryRun: z.boolean().optional().describe('When true, report what would change without writing'),
1048
- to: z.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`),
1049
- }).strict(),
1050
- outputSchema: {
1051
- policyPath: z.string(),
1052
- fileVersion: z.string().optional(),
1053
- targetVersion: z.string(),
1054
- supportedVersions: z.array(z.string()),
1055
- status: z.enum([
1056
- 'already-current',
1057
- 'migrated',
1058
- 'dry-run',
1059
- 'no-version-field',
1060
- 'unsupported',
1061
- 'precheck-failed',
1062
- 'file-not-found',
1063
- ]),
1064
- from: z.string().optional(),
1065
- to: z.string().optional(),
1066
- bytesWritten: z.number().optional(),
1067
- message: z.string(),
1068
- errors: z
1069
- .array(z.object({ path: z.string(), keyword: z.string(), message: z.string() }))
1070
- .optional(),
1071
- },
1072
- }, async ({ path: pathArg, dryRun, to }) => {
1073
- const policyPath = resolvePolicyPath({ flag: pathArg });
1074
- const target = (to ?? LATEST_SUPPORTED_VERSION);
1075
- let loaded;
1076
- try {
1077
- loaded = loadPolicyFile(policyPath);
1078
- }
1079
- catch (err) {
1080
- if (err instanceof PolicyFileNotFoundError) {
1081
- const structured = {
1082
- policyPath,
1083
- targetVersion: target,
1084
- supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
1085
- status: 'file-not-found',
1086
- message: `policy file not found: ${policyPath}`,
1087
- };
1088
- return {
1089
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1090
- structuredContent: structured,
1091
- };
1092
- }
1093
- throw err;
1094
- }
1095
- const data = loaded.data;
1096
- const fileVersion = typeof data?.version === 'string' ? data.version : undefined;
1097
- const base = {
1098
- policyPath,
1099
- fileVersion,
1100
- targetVersion: target,
1101
- supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
1102
- };
1103
- if (!fileVersion) {
1104
- const structured = {
1105
- ...base,
1106
- status: 'no-version-field',
1107
- message: `policy has no \`version\` field — add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\``,
1108
- };
1109
- return {
1110
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1111
- structuredContent: structured,
1112
- };
1113
- }
1114
- if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
1115
- const structured = {
1116
- ...base,
1117
- status: 'unsupported',
1118
- message: `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(', ')})`,
1119
- };
1120
- return {
1121
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1122
- structuredContent: structured,
1123
- };
1124
- }
1125
- if (fileVersion === target) {
1126
- const structured = {
1127
- ...base,
1128
- status: 'already-current',
1129
- message: `already on schema v${target}; no migration needed`,
1130
- bytesWritten: 0,
1131
- };
1132
- return {
1133
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1134
- structuredContent: structured,
1135
- };
1136
- }
1137
- const plan = planMigration(loaded, fileVersion, target);
1138
- if (!plan.precheck.valid) {
1139
- const structured = {
1140
- ...base,
1141
- status: 'precheck-failed',
1142
- message: `migrated policy fails schema v${target} precheck; file not written`,
1143
- errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message })),
1144
- };
1145
- return {
1146
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1147
- structuredContent: structured,
1148
- };
1149
- }
1150
- const bytes = Buffer.byteLength(plan.nextSource, 'utf-8');
1151
- if (dryRun) {
1152
- const structured = {
1153
- ...base,
1154
- status: 'dry-run',
1155
- from: plan.fromVersion,
1156
- to: plan.toVersion,
1157
- bytesWritten: 0,
1158
- message: `dry-run: would upgrade v${plan.fromVersion} → v${plan.toVersion} (${bytes} bytes)`,
1159
- };
1160
- return {
1161
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1162
- structuredContent: structured,
1163
- };
1164
- }
1165
- writeFileSync(policyPath, plan.nextSource, { encoding: 'utf-8' });
1166
- const structured = {
1167
- ...base,
1168
- status: 'migrated',
1169
- from: plan.fromVersion,
1170
- to: plan.toVersion,
1171
- bytesWritten: bytes,
1172
- message: `migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`,
1173
- };
1174
- return {
1175
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1176
- structuredContent: structured,
1177
- };
1178
- });
1179
- // ---- policy_diff ---------------------------------------------------------
1180
- server.registerTool('policy_diff', {
1181
- title: 'Compare two policy files',
1182
- description: 'Compare two policy YAML files and return the same contract as `switchbot --json policy diff`: ' +
1183
- '{ leftPath, rightPath, equal, changeCount, truncated, stats, changes, diff }.',
1184
- _meta: { agentSafetyTier: 'read' },
1185
- inputSchema: z.object({
1186
- left_path: z.string().min(1).describe('Path to the baseline policy file.'),
1187
- right_path: z.string().min(1).describe('Path to the candidate policy file.'),
1188
- }).strict(),
1189
- outputSchema: {
1190
- leftPath: z.string(),
1191
- rightPath: z.string(),
1192
- equal: z.boolean(),
1193
- changeCount: z.number().int(),
1194
- truncated: z.boolean(),
1195
- stats: z.object({
1196
- added: z.number().int(),
1197
- removed: z.number().int(),
1198
- changed: z.number().int(),
1199
- }),
1200
- changes: z.array(z.object({
1201
- path: z.string(),
1202
- kind: z.enum(['added', 'removed', 'changed']),
1203
- before: z.unknown().optional(),
1204
- after: z.unknown().optional(),
1205
- })),
1206
- diff: z.string(),
1207
- },
1208
- }, ({ left_path, right_path }) => {
1209
- let leftSource = '';
1210
- let rightSource = '';
1211
- try {
1212
- leftSource = fs.readFileSync(left_path, 'utf-8');
1213
- }
1214
- catch (err) {
1215
- if (err?.code === 'ENOENT') {
1216
- return mcpError('usage', 2, `policy file not found: ${left_path}`, {
1217
- context: { policyPath: left_path },
1218
- });
1219
- }
1220
- return mcpError('runtime', 1, `failed to read ${left_path}: ${String(err)}`);
1221
- }
1222
- try {
1223
- rightSource = fs.readFileSync(right_path, 'utf-8');
1224
- }
1225
- catch (err) {
1226
- if (err?.code === 'ENOENT') {
1227
- return mcpError('usage', 2, `policy file not found: ${right_path}`, {
1228
- context: { policyPath: right_path },
1229
- });
1230
- }
1231
- return mcpError('runtime', 1, `failed to read ${right_path}: ${String(err)}`);
1232
- }
1233
- let leftDoc;
1234
- let rightDoc;
1235
- try {
1236
- leftDoc = yamlParse(leftSource);
1237
- }
1238
- catch (err) {
1239
- return mcpError('usage', 2, `YAML parse error in ${left_path}: ${err.message}`);
1240
- }
1241
- try {
1242
- rightDoc = yamlParse(rightSource);
1243
- }
1244
- catch (err) {
1245
- return mcpError('usage', 2, `YAML parse error in ${right_path}: ${err.message}`);
1246
- }
1247
- const result = {
1248
- leftPath: left_path,
1249
- rightPath: right_path,
1250
- ...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource),
1251
- };
1252
- return {
1253
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1254
- structuredContent: result,
1255
- };
1256
- });
1257
- // switchbot://events resource — snapshot of recent shadow events from the ring buffer.
1258
- // Returns up to 100 recent events. When MQTT is disabled, returns an empty list with a state note.
1259
- // URI: switchbot://events (optional query: ?filter=<expression> ?limit=<n>)
1260
- if (eventManager) {
1261
- server.registerResource('events', 'switchbot://events', {
1262
- title: 'SwitchBot real-time shadow events',
1263
- description: 'Recent device shadow-update events received via MQTT. Returns a JSON snapshot of the ring buffer. ' +
1264
- 'State is "disabled" when REST credentials (SWITCHBOT_TOKEN + SWITCHBOT_SECRET) are not configured.',
1265
- mimeType: 'application/json',
1266
- }, (_uri) => {
1267
- const state = eventManager.getState();
1268
- const events = state !== 'disabled' ? eventManager.getRecentEvents(100) : [];
1269
- return {
1270
- contents: [{
1271
- uri: 'switchbot://events',
1272
- mimeType: 'application/json',
1273
- text: JSON.stringify({ state, count: events.length, events }, null, 2),
1274
- }],
1275
- };
1276
- });
1277
- }
1278
- // ---- plan_suggest ---------------------------------------------------------
1279
- server.registerTool('plan_suggest', {
1280
- title: 'Draft a SwitchBot execution plan from intent',
1281
- description: 'Generate a candidate Plan JSON from a natural language intent and a list of device IDs. ' +
1282
- 'Uses keyword heuristics (no LLM) to pick the command. The returned plan is ready to pass to ' +
1283
- '`plan run` — review and edit before executing. Recognised commands: turnOn, turnOff, press, ' +
1284
- 'lock, unlock, open, close, pause. Falls back to turnOn with a warning when intent is unclear.',
1285
- _meta: { agentSafetyTier: 'read' },
1286
- inputSchema: z.object({
1287
- intent: z.string().min(1).describe('Natural language description of what to do (e.g. "turn off all lights").'),
1288
- device_ids: z.array(z.string().min(1)).min(1).describe('Device IDs to act on.'),
1289
- }).strict(),
1290
- outputSchema: {
1291
- plan: z.unknown().describe('Candidate Plan JSON (version 1.0) ready to pass to plan run.'),
1292
- warnings: z.array(z.string()).describe('Informational warnings (e.g. unrecognized intent defaulted to turnOn).'),
1293
- },
1294
- }, ({ intent, device_ids }) => {
1295
- const devices = device_ids.map((id) => {
1296
- const cached = getCachedDevice(id);
1297
- return { id, name: cached?.name, type: cached?.type };
1298
- });
1299
- try {
1300
- const { plan, warnings } = suggestPlan({ intent, devices });
1301
- return {
1302
- content: [{ type: 'text', text: JSON.stringify({ plan, warnings }, null, 2) }],
1303
- structuredContent: { plan, warnings },
1304
- };
1305
- }
1306
- catch (err) {
1307
- return apiErrorToMcpError(err);
1308
- }
1309
- });
1310
- // ---- plan_run -------------------------------------------------------------
1311
- server.registerTool('plan_run', {
1312
- title: 'Validate and execute a SwitchBot plan',
1313
- 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. ' +
1314
- 'Scene and wait steps run in order. Returns per-step results and a summary.',
1315
- _meta: { agentSafetyTier: 'action' },
1316
- inputSchema: z.object({
1317
- plan: z.unknown().describe('Plan JSON object (same schema as `switchbot plan run`).'),
1318
- yes: z.boolean().optional().describe('Authorize destructive command steps.'),
1319
- continue_on_error: z.boolean().optional().describe('Keep executing later steps after a failed step.'),
1320
- }).strict(),
1321
- outputSchema: {
1322
- ran: z.boolean(),
1323
- plan: z.unknown(),
1324
- results: z.array(z.unknown()),
1325
- summary: z.object({
1326
- total: z.number().int(),
1327
- ok: z.number().int(),
1328
- error: z.number().int(),
1329
- skipped: z.number().int(),
1330
- }),
1331
- },
1332
- }, async ({ plan, yes, continue_on_error }) => {
1333
- const validated = validatePlan(plan);
1334
- if (!validated.ok) {
1335
- return mcpError('usage', 2, 'plan invalid', {
1336
- context: { issues: validated.issues },
1337
- hint: 'Fix the reported issues and retry plan_run.',
1338
- });
1339
- }
1340
- const out = {
1341
- ran: true,
1342
- plan: validated.plan,
1343
- results: [],
1344
- summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 },
1345
- };
1346
- const continueOnError = continue_on_error === true;
1347
- const allowDestructive = yes === true;
1348
- const destructiveSteps = validated.plan.steps
1349
- .map((step, index) => ({ step, index }))
1350
- .filter((entry) => entry.step.type === 'command')
1351
- .map(({ step, index }) => {
1352
- const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
1353
- const commandType = step.commandType ?? 'command';
1354
- const deviceType = getCachedDevice(resolvedDeviceId)?.type;
1355
- return {
1356
- index: index + 1,
1357
- deviceId: resolvedDeviceId,
1358
- command: step.command,
1359
- commandType,
1360
- deviceType: deviceType ?? null,
1361
- destructive: isDestructiveCommand(deviceType, step.command, commandType),
1362
- };
1363
- })
1364
- .filter((step) => step.destructive);
1365
- if (allowDestructive && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
1366
- return mcpError('guard', 3, 'Direct destructive execution is disabled for plan_run.', {
1367
- hint: destructiveExecutionHint(),
1368
- context: {
1369
- destructiveSteps: destructiveSteps.map((step) => ({
1370
- step: step.index,
1371
- deviceId: step.deviceId,
1372
- deviceType: step.deviceType,
1373
- command: step.command,
1374
- commandType: step.commandType,
1375
- })),
1376
- requiredWorkflow: 'plan-approval',
1377
- },
1378
- });
1379
- }
1380
- for (let i = 0; i < validated.plan.steps.length; i++) {
1381
- const step = validated.plan.steps[i];
1382
- const idx = i + 1;
1383
- if (step.type === 'wait') {
1384
- await new Promise((resolve) => setTimeout(resolve, step.ms));
1385
- out.results.push({ step: idx, type: 'wait', ms: step.ms, status: 'ok' });
1386
- out.summary.ok++;
1387
- continue;
1388
- }
1389
- if (step.type === 'scene') {
1390
- try {
1391
- await executeScene(step.sceneId);
1392
- out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'ok' });
1393
- out.summary.ok++;
1394
- }
1395
- catch (err) {
1396
- const msg = err instanceof Error ? err.message : String(err);
1397
- out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'error', error: msg });
1398
- out.summary.error++;
1399
- if (!continueOnError)
1400
- break;
1401
- }
1402
- continue;
1403
- }
1404
- let resolvedDeviceId = '';
1405
- try {
1406
- resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
1407
- const commandType = step.commandType ?? 'command';
1408
- const deviceType = getCachedDevice(resolvedDeviceId)?.type;
1409
- const destructive = isDestructiveCommand(deviceType, step.command, commandType);
1410
- if (destructive && !allowDestructive) {
1411
- out.results.push({
1412
- step: idx,
1413
- type: 'command',
1414
- deviceId: resolvedDeviceId,
1415
- command: step.command,
1416
- status: 'skipped',
1417
- error: 'destructive — rerun with yes=true',
1418
- });
1419
- out.summary.skipped++;
1420
- if (!continueOnError)
1421
- break;
1422
- continue;
1423
- }
1424
- await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
1425
- out.results.push({
1426
- step: idx,
1427
- type: 'command',
1428
- deviceId: resolvedDeviceId,
1429
- command: step.command,
1430
- status: 'ok',
1431
- });
1432
- out.summary.ok++;
1433
- }
1434
- catch (err) {
1435
- if (err instanceof Error && err.name === 'DryRunSignal') {
1436
- out.results.push({
1437
- step: idx,
1438
- type: 'command',
1439
- deviceId: resolvedDeviceId || step.deviceId || 'unknown',
1440
- command: step.command,
1441
- status: 'ok',
1442
- });
1443
- out.summary.ok++;
1444
- continue;
1445
- }
1446
- const msg = err instanceof Error ? err.message : String(err);
1447
- out.results.push({
1448
- step: idx,
1449
- type: 'command',
1450
- deviceId: resolvedDeviceId || step.deviceId || 'unknown',
1451
- command: step.command,
1452
- status: 'error',
1453
- error: msg,
1454
- });
1455
- out.summary.error++;
1456
- if (!continueOnError)
1457
- break;
1458
- }
1459
- }
1460
- return {
1461
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1462
- structuredContent: out,
1463
- };
1464
- });
1465
- // ---- audit_query ----------------------------------------------------------
1466
- server.registerTool('audit_query', {
1467
- title: 'Query command/rule audit log entries',
1468
- description: 'Filter entries from the local audit log (default ~/.switchbot/audit.log) by time range, kind, device, rule, and result. ' +
1469
- 'Useful for review flows and rule-fire inspection without leaving MCP.',
1470
- _meta: { agentSafetyTier: 'read' },
1471
- inputSchema: z.object({
1472
- file: z.string().optional().describe('Optional audit log path; defaults to ~/.switchbot/audit.log.'),
1473
- since: z.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
1474
- from: z.string().optional().describe('Range start (ISO-8601).'),
1475
- to: z.string().optional().describe('Range end (ISO-8601).'),
1476
- kinds: z.array(z.enum(['command', 'rule-fire', 'rule-fire-dry', 'rule-throttled', 'rule-webhook-rejected'])).optional().describe('Filter by entry kind.'),
1477
- device_id: z.string().optional().describe('Filter by deviceId.'),
1478
- rule_name: z.string().optional().describe('Filter by rule.name (rule-engine entries).'),
1479
- results: z.array(z.enum(['ok', 'error'])).optional().describe('Filter by execution result.'),
1480
- limit: z.number().int().min(1).max(5000).optional().describe('Max entries returned from the tail of the filtered set (default 200).'),
1481
- }).strict(),
1482
- outputSchema: {
1483
- file: z.string(),
1484
- totalMatched: z.number().int(),
1485
- returned: z.number().int(),
1486
- entries: z.array(z.unknown()),
1487
- },
1488
- }, ({ file, since, from, to, kinds, device_id, rule_name, results, limit }) => {
1489
- const filePath = file ?? DEFAULT_AUDIT_LOG_FILE;
1490
- const entries = readAudit(filePath);
1491
- try {
1492
- const filtered = filterAuditEntries(entries, {
1493
- since,
1494
- from,
1495
- to,
1496
- kinds,
1497
- deviceId: device_id,
1498
- ruleName: rule_name,
1499
- results,
1500
- });
1501
- const bounded = filtered.slice(-Math.max(1, limit ?? 200));
1502
- const out = {
1503
- file: filePath,
1504
- totalMatched: filtered.length,
1505
- returned: bounded.length,
1506
- entries: bounded,
1507
- };
1508
- return {
1509
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1510
- structuredContent: out,
1511
- };
1512
- }
1513
- catch (err) {
1514
- return mcpError('usage', 2, err instanceof Error ? err.message : 'invalid audit query options');
1515
- }
1516
- });
1517
- // ---- audit_stats ----------------------------------------------------------
1518
- server.registerTool('audit_stats', {
1519
- title: 'Aggregate audit log counts for review dashboards',
1520
- description: 'Compute summary counters over the local audit log: by kind, by result, top devices, and top rules. ' +
1521
- 'Supports the same filters as audit_query.',
1522
- _meta: { agentSafetyTier: 'read' },
1523
- inputSchema: z.object({
1524
- file: z.string().optional().describe('Optional audit log path; defaults to ~/.switchbot/audit.log.'),
1525
- since: z.string().optional().describe('Relative window ending now (e.g. "6h"). Mutually exclusive with from/to.'),
1526
- from: z.string().optional().describe('Range start (ISO-8601).'),
1527
- to: z.string().optional().describe('Range end (ISO-8601).'),
1528
- kinds: z.array(z.enum(['command', 'rule-fire', 'rule-fire-dry', 'rule-throttled', 'rule-webhook-rejected'])).optional().describe('Filter by entry kind.'),
1529
- device_id: z.string().optional().describe('Filter by deviceId.'),
1530
- rule_name: z.string().optional().describe('Filter by rule.name (rule-engine entries).'),
1531
- results: z.array(z.enum(['ok', 'error'])).optional().describe('Filter by execution result.'),
1532
- top_n: z.number().int().min(1).max(100).optional().describe('Number of top device/rule rows to return (default 10).'),
1533
- }).strict(),
1534
- outputSchema: {
1535
- file: z.string(),
1536
- totalMatched: z.number().int(),
1537
- byKind: z.record(z.string(), z.number().int()),
1538
- byResult: z.record(z.string(), z.number().int()),
1539
- topDevices: z.array(z.object({ deviceId: z.string(), count: z.number().int() })),
1540
- topRules: z.array(z.object({ ruleName: z.string(), count: z.number().int() })),
1541
- },
1542
- }, ({ file, since, from, to, kinds, device_id, rule_name, results, top_n }) => {
1543
- const filePath = file ?? DEFAULT_AUDIT_LOG_FILE;
1544
- const entries = readAudit(filePath);
1545
- try {
1546
- const filtered = filterAuditEntries(entries, {
1547
- since,
1548
- from,
1549
- to,
1550
- kinds,
1551
- deviceId: device_id,
1552
- ruleName: rule_name,
1553
- results,
1554
- });
1555
- const byKind = new Map();
1556
- const byResult = new Map();
1557
- const byDevice = new Map();
1558
- const byRule = new Map();
1559
- for (const entry of filtered) {
1560
- byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + 1);
1561
- if (entry.result)
1562
- byResult.set(entry.result, (byResult.get(entry.result) ?? 0) + 1);
1563
- if (entry.deviceId)
1564
- byDevice.set(entry.deviceId, (byDevice.get(entry.deviceId) ?? 0) + 1);
1565
- if (entry.rule?.name)
1566
- byRule.set(entry.rule.name, (byRule.get(entry.rule.name) ?? 0) + 1);
1567
- }
1568
- const topN = top_n ?? 10;
1569
- const out = {
1570
- file: filePath,
1571
- totalMatched: filtered.length,
1572
- byKind: Object.fromEntries([...byKind.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
1573
- byResult: Object.fromEntries([...byResult.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
1574
- topDevices: topNFromMap(byDevice, topN).map((item) => ({ deviceId: item.key, count: item.count })),
1575
- topRules: topNFromMap(byRule, topN).map((item) => ({ ruleName: item.key, count: item.count })),
1576
- };
1577
- return {
1578
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1579
- structuredContent: out,
1580
- };
1581
- }
1582
- catch (err) {
1583
- return mcpError('usage', 2, err instanceof Error ? err.message : 'invalid audit stats options');
1584
- }
1585
- });
1586
- // ---- rules_suggest --------------------------------------------------------
1587
- server.registerTool('rules_suggest', {
1588
- title: 'Draft a SwitchBot automation rule from intent',
1589
- description: 'Generate a candidate automation rule YAML from a natural language intent. ' +
1590
- 'Uses keyword heuristics (no LLM) to infer trigger, schedule, and command. ' +
1591
- 'Always emits dry_run: true — the rule must be reviewed before arming. ' +
1592
- 'Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.',
1593
- _meta: { agentSafetyTier: 'read' },
1594
- inputSchema: z.object({
1595
- intent: z.string().min(1).describe('Natural language description (e.g. "turn off lights at 10pm").'),
1596
- trigger: z.enum(['mqtt', 'cron', 'webhook']).optional().describe('Trigger type (inferred from intent if omitted).'),
1597
- device_ids: z.array(z.string().min(1)).optional().describe('Device IDs; first is sensor for mqtt triggers, rest are action targets.'),
1598
- event: z.string().optional().describe('MQTT event name override (e.g. motion.detected).'),
1599
- schedule: z.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
1600
- days: z.array(z.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
1601
- webhook_path: z.string().optional().describe('Webhook path override (default /action).'),
1602
- }).strict(),
1603
- outputSchema: {
1604
- rule: z.unknown().describe('Rule object matching the v0.2 policy schema.'),
1605
- rule_yaml: z.string().describe('YAML string ready to pipe to policy_add_rule.'),
1606
- warnings: z.array(z.string()).describe('Informational warnings (e.g. unrecognized intent defaulted).'),
1607
- },
1608
- }, ({ intent, trigger, device_ids, event, schedule, days, webhook_path }) => {
1609
- const devices = (device_ids ?? []).map((id) => {
1610
- const cached = getCachedDevice(id);
1611
- return { id, name: cached?.name, type: cached?.type };
1612
- });
1613
- try {
1614
- const { rule, ruleYaml, warnings } = suggestRule({
1615
- intent,
1616
- trigger,
1617
- devices,
1618
- event,
1619
- schedule,
1620
- days,
1621
- webhookPath: webhook_path,
1622
- });
1623
- return {
1624
- content: [{ type: 'text', text: ruleYaml }],
1625
- structuredContent: { rule, rule_yaml: ruleYaml, warnings },
1626
- };
1627
- }
1628
- catch (err) {
1629
- return apiErrorToMcpError(err);
1630
- }
1631
- });
1632
- // ---- policy_add_rule ------------------------------------------------------
1633
- server.registerTool('policy_add_rule', {
1634
- title: 'Append a rule to automation.rules[] in policy.yaml',
1635
- description: 'Inject a rule YAML snippet (as produced by rules_suggest) into the automation.rules[] ' +
1636
- 'array in policy.yaml. Preserves existing comments and formatting. ' +
1637
- 'Always run with dry_run: true first so the agent can show the diff for user approval. ' +
1638
- 'Never set enable_automation: true without explicitly informing the user.',
1639
- _meta: { agentSafetyTier: 'action' },
1640
- inputSchema: z.object({
1641
- rule_yaml: z.string().min(1).describe('YAML string of a single rule object (e.g. from rules_suggest).'),
1642
- policy_path: z.string().optional().describe('Path to policy.yaml (defaults to $SWITCHBOT_POLICY_PATH or ~/.switchbot/policy.yaml).'),
1643
- enable_automation: z.boolean().default(false).describe('If true, sets automation.enabled: true after inserting the rule.'),
1644
- dry_run: z.boolean().default(false).describe('If true, compute and return the diff without writing to disk.'),
1645
- force: z.boolean().default(false).describe('If true, overwrite an existing rule with the same name.'),
1646
- }).strict(),
1647
- outputSchema: {
1648
- policyPath: z.string().describe('Resolved path to the policy file.'),
1649
- ruleName: z.string().describe('Name of the rule that was (or would be) inserted.'),
1650
- written: z.boolean().describe('True when the file was actually written.'),
1651
- diff: z.string().describe('Unified-style diff showing lines added/removed.'),
1652
- },
1653
- }, ({ rule_yaml, policy_path, enable_automation, dry_run, force }) => {
1654
- const policyPath = resolvePolicyPath({ flag: policy_path });
1655
- try {
1656
- const result = addRuleToPolicyFile({
1657
- ruleYaml: rule_yaml,
1658
- policyPath,
1659
- enableAutomation: enable_automation,
1660
- dryRun: dry_run,
1661
- force,
1662
- });
1663
- const out = { policyPath, ruleName: result.ruleName, written: result.written, diff: result.diff };
1664
- return {
1665
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1666
- structuredContent: out,
1667
- };
1668
- }
1669
- catch (err) {
1670
- if (err instanceof AddRuleError) {
1671
- return apiErrorToMcpError(new Error(`${err.code}: ${err.message}`));
1672
- }
1673
- return apiErrorToMcpError(err);
1674
- }
1675
- });
1676
- return server;
1677
- }
1678
- /**
1679
- * P10: list the tool names registered on an McpServer instance. Used by
1680
- * `doctor`'s dry-run check. The MCP SDK keeps `_registeredTools` private,
1681
- * so we reach through a narrow cast — safe because this only runs in
1682
- * diagnostic code and the shape is stable across SDK versions.
1683
- */
1684
- export function listRegisteredTools(server) {
1685
- const internal = server;
1686
- if (!internal._registeredTools)
1687
- return [];
1688
- return Object.keys(internal._registeredTools).sort();
1689
- }
1690
- export function registerMcpCommand(program) {
1691
- const mcp = program
1692
- .command('mcp')
1693
- .description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
1694
- .addHelpText('after', `
1695
- The MCP server exposes twenty-one tools:
1696
- - list_devices fetch all physical + IR devices
1697
- - get_device_status live status for a physical device
1698
- - send_command control a device (destructive commands need confirm:true)
1699
- - list_scenes list all manual scenes
1700
- - run_scene execute a manual scene
1701
- - search_catalog offline catalog search by type/alias
1702
- - describe_device metadata + commands + (optionally) live status for one device
1703
- - account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state
1704
- - get_device_history fetch raw JSONL history records for a device
1705
- - query_device_history filter + page history records with field/time predicates
1706
- - aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records
1707
- - policy_validate check policy.yaml against the embedded schema (v0.1 / v0.2)
1708
- - policy_new scaffold a starter policy.yaml (action — confirm first)
1709
- - policy_migrate upgrade policy.yaml to the latest schema (action — preserves comments)
1710
- - policy_diff compare two policy files with structural + line diff output
1711
- - plan_suggest draft a Plan JSON from intent + device IDs (heuristic, no LLM)
1712
- - plan_run validate + execute a Plan JSON document
1713
- - audit_query filter audit log entries by time/device/rule/result
1714
- - audit_stats aggregate audit counts by kind/result/device/rule
1715
- - rules_suggest draft an automation rule YAML from intent (heuristic, no LLM)
1716
- - policy_add_rule append a rule into automation.rules[] in policy.yaml
1717
-
1718
- Resource (read-only):
1719
- - switchbot://events snapshot of recent MQTT shadow events from the ring buffer
1720
- Auto-provisioned from SWITCHBOT_TOKEN + SWITCHBOT_SECRET;
1721
- returns {state:"disabled"} when credentials are not configured.
1722
-
1723
- Example Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
1724
-
1725
- {
1726
- "mcpServers": {
1727
- "switchbot": {
1728
- "command": "switchbot",
1729
- "args": ["mcp", "serve"],
1730
- "env": {
1731
- "SWITCHBOT_TOKEN": "...",
1732
- "SWITCHBOT_SECRET": "..."
1733
- }
1734
- }
1735
- }
1736
- }
1737
-
1738
- Inspect locally:
1739
- $ npx @modelcontextprotocol/inspector switchbot mcp serve
1740
- `);
1741
- mcp
1742
- .command('serve')
1743
- .description('Start the MCP server on stdio (default) or HTTP (--port)')
1744
- .option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)', intArg('--port', { min: 1, max: 65535 }))
1745
- .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')
1746
- .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'))
1747
- .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
1748
- .option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60')
1749
- .addHelpText('after', `
1750
- Examples:
1751
- $ switchbot mcp serve
1752
- $ switchbot mcp serve --port 8787
1753
- $ switchbot mcp serve --port 8787 --bind 127.0.0.1 --auth-token your-token
1754
- $ switchbot mcp serve --port 8787 --bind 0.0.0.0 --auth-token your-token
1755
- `)
1756
- .action(async (options) => {
1757
- try {
1758
- if (options.port) {
1759
- const port = Number(options.port);
1760
- if (!Number.isFinite(port) || port < 1 || port > 65535) {
1761
- exitWithError(`Invalid --port "${options.port}". Must be 1-65535.`);
1762
- }
1763
- const bind = options.bind ?? '127.0.0.1';
1764
- const authToken = options.authToken ?? process.env.SWITCHBOT_MCP_TOKEN;
1765
- const corsOrigins = Array.isArray(options.corsOrigin) ? options.corsOrigin : (options.corsOrigin ? [options.corsOrigin] : []);
1766
- const rateLimit = Math.max(1, Number(options.rateLimit) || 60);
1767
- // Guard: refuse to bind non-localhost without auth
1768
- const isLocalhost = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1';
1769
- if (!isLocalhost && !authToken) {
1770
- exitWithError('Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).');
1771
- }
1772
- const { createServer } = await import('node:http');
1773
- const rateLimitMap = new Map();
1774
- // Initialize shared EventSubscriptionManager for event streaming.
1775
- // Credentials are auto-provisioned from the SwitchBot API using the
1776
- // account's token+secret — no extra MQTT env vars needed.
1777
- const eventManager = new EventSubscriptionManager();
1778
- const mqttCreds = tryLoadConfig();
1779
- if (mqttCreds) {
1780
- eventManager.initialize(mqttCreds.token, mqttCreds.secret).catch((err) => {
1781
- console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err));
1782
- });
1783
- }
1784
- else {
1785
- console.error('MQTT disabled: credentials not configured.');
1786
- }
1787
- // Helper: constant-time token comparison
1788
- const tokenMatch = (provided) => {
1789
- if (!authToken)
1790
- return true; // No token configured, allow all
1791
- if (!provided)
1792
- return false;
1793
- const expected = authToken;
1794
- let match = true;
1795
- for (let i = 0; i < Math.max(expected.length, provided.length); i++) {
1796
- if ((expected[i] ?? '\0') !== (provided[i] ?? '\0'))
1797
- match = false;
1798
- }
1799
- return match;
1800
- };
1801
- // Helper: rate limit check
1802
- const checkRateLimit = (profile) => {
1803
- const now = Date.now();
1804
- const bucket = rateLimitMap.get(profile);
1805
- if (!bucket || now >= bucket.resetAt) {
1806
- rateLimitMap.set(profile, { count: 1, resetAt: now + 60000 });
1807
- return true;
1808
- }
1809
- bucket.count++;
1810
- return bucket.count <= rateLimit;
1811
- };
1812
- const httpServer = createServer(async (req, res) => {
1813
- // Health and metrics routes (no auth required)
1814
- if (req.url === '/healthz' && req.method === 'GET') {
1815
- res.writeHead(200, { 'Content-Type': 'application/json' });
1816
- res.end(JSON.stringify({
1817
- ok: true,
1818
- version: VERSION,
1819
- pid: process.pid,
1820
- uptimeSec: Math.floor(process.uptime()),
1821
- }));
1822
- return;
1823
- }
1824
- if (req.url === '/ready' && req.method === 'GET') {
1825
- const state = eventManager.getState();
1826
- const ready = state !== 'failed' && state !== 'disabled';
1827
- const status = ready ? 200 : 503;
1828
- const body = { ready, version: VERSION, mqtt: state };
1829
- if (!ready)
1830
- body.reason = state === 'disabled' ? 'mqtt disabled' : 'mqtt failed';
1831
- res.writeHead(status, { 'Content-Type': 'application/json' });
1832
- res.end(JSON.stringify(body));
1833
- return;
1834
- }
1835
- if (req.url === '/metrics' && req.method === 'GET') {
1836
- const mqttState = eventManager.getState();
1837
- const metrics = `# HELP switchbot_mqtt_connected MQTT connection status (0=disconnected, 1=connected)
1838
- # TYPE switchbot_mqtt_connected gauge
1839
- switchbot_mqtt_connected ${mqttState === 'connected' ? 1 : 0}
1840
-
1841
- # HELP switchbot_mqtt_state Current MQTT state (1 for the active state, 0 otherwise)
1842
- # TYPE switchbot_mqtt_state gauge
1843
- switchbot_mqtt_state{state="disabled"} ${mqttState === 'disabled' ? 1 : 0}
1844
- switchbot_mqtt_state{state="connecting"} ${mqttState === 'connecting' ? 1 : 0}
1845
- switchbot_mqtt_state{state="connected"} ${mqttState === 'connected' ? 1 : 0}
1846
- switchbot_mqtt_state{state="reconnecting"} ${mqttState === 'reconnecting' ? 1 : 0}
1847
- switchbot_mqtt_state{state="failed"} ${mqttState === 'failed' ? 1 : 0}
1848
-
1849
- # HELP switchbot_mqtt_subscribers Number of active event subscribers
1850
- # TYPE switchbot_mqtt_subscribers gauge
1851
- switchbot_mqtt_subscribers ${eventManager.getSubscriberCount()}
1852
-
1853
- # HELP process_uptime_seconds Process uptime in seconds
1854
- # TYPE process_uptime_seconds gauge
1855
- process_uptime_seconds ${Math.floor(process.uptime())}
1856
- `;
1857
- res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' });
1858
- res.end(metrics);
1859
- return;
1860
- }
1861
- // Extract profile from header or query string
1862
- const headerProfile = req.headers['x-switchbot-profile'];
1863
- const profileHeader = Array.isArray(headerProfile) ? headerProfile[0] : headerProfile;
1864
- let profileQuery;
1865
- try {
1866
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
1867
- profileQuery = url.searchParams.get('profile') ?? undefined;
1868
- }
1869
- catch { /* ignore */ }
1870
- const profile = profileHeader || profileQuery;
1871
- // CORS preflight
1872
- if (req.method === 'OPTIONS') {
1873
- if (corsOrigins.length > 0) {
1874
- const origin = req.headers.origin;
1875
- if (origin && corsOrigins.includes(origin)) {
1876
- res.writeHead(200, {
1877
- 'Access-Control-Allow-Origin': origin,
1878
- 'Access-Control-Allow-Methods': 'POST, OPTIONS',
1879
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
1880
- });
1881
- res.end();
1882
- return;
1883
- }
1884
- }
1885
- res.writeHead(204);
1886
- res.end();
1887
- return;
1888
- }
1889
- // Rate limit check
1890
- if (!checkRateLimit(profile ?? 'default')) {
1891
- res.writeHead(429, { 'Content-Type': 'application/json' });
1892
- res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Rate limit exceeded' }, id: null }));
1893
- return;
1894
- }
1895
- // Auth check
1896
- const authHeader = req.headers.authorization;
1897
- const [scheme, token] = (authHeader ?? '').split(' ');
1898
- if (authToken && (scheme !== 'Bearer' || !tokenMatch(token))) {
1899
- res.writeHead(401, { 'Content-Type': 'application/json' });
1900
- res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null }));
1901
- return;
1902
- }
1903
- // CORS headers for allowed origins
1904
- if (corsOrigins.length > 0) {
1905
- const origin = req.headers.origin;
1906
- if (origin && corsOrigins.includes(origin)) {
1907
- res.setHeader('Access-Control-Allow-Origin', origin);
1908
- }
1909
- }
1910
- // Reject unknown profiles early: avoids confusing downstream credential
1911
- // errors and protects against probing for valid profile names.
1912
- if (profile) {
1913
- const envCredsPresent = !!(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
1914
- if (!envCredsPresent && !fs.existsSync(profileFilePath(profile))) {
1915
- res.writeHead(401, { 'Content-Type': 'application/json' });
1916
- res.end(JSON.stringify({
1917
- jsonrpc: '2.0',
1918
- error: { code: -32001, message: `Unknown profile: ${profile}` },
1919
- id: null,
1920
- }));
1921
- return;
1922
- }
1923
- }
1924
- // Stateless mode: fresh transport+server per request (SDK requirement).
1925
- const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
1926
- const reqServer = createSwitchBotMcpServer({ eventManager });
1927
- // Register cleanup before any async work so it fires on both normal
1928
- // close and error-path close (after the 500 response ends).
1929
- res.on('close', () => {
1930
- reqTransport.close();
1931
- reqServer.close();
1932
- });
1933
- // Route per-request credentials via AsyncLocalStorage so loadConfig()
1934
- // picks up this request's profile instead of the process-global flag.
1935
- await withRequestContext({ profile: profile ?? undefined }, async () => {
1936
- try {
1937
- await reqServer.connect(reqTransport);
1938
- await reqTransport.handleRequest(req, res);
1939
- }
1940
- catch (err) {
1941
- if (!res.headersSent) {
1942
- res.writeHead(500, { 'Content-Type': 'application/json' });
1943
- res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null }));
1944
- }
1945
- }
1946
- });
1947
- });
1948
- // Graceful shutdown
1949
- let isShuttingDown = false;
1950
- const gracefulShutdown = async () => {
1951
- if (isShuttingDown)
1952
- return;
1953
- isShuttingDown = true;
1954
- console.error('Shutting down...');
1955
- await eventManager.shutdown();
1956
- httpServer.close(() => {
1957
- console.error('Server closed');
1958
- process.exit(0);
1959
- });
1960
- // Force exit after 30s
1961
- setTimeout(() => {
1962
- console.error('Force exiting after 30s timeout');
1963
- process.exit(1);
1964
- }, 30000);
1965
- };
1966
- process.on('SIGTERM', gracefulShutdown);
1967
- process.on('SIGINT', gracefulShutdown);
1968
- httpServer.listen(port, bind, () => {
1969
- console.error(`SwitchBot MCP server listening on http://${bind}:${port}/mcp`);
1970
- if (authToken) {
1971
- console.error(' Authentication: required (Bearer token)');
1972
- }
1973
- if (corsOrigins.length > 0) {
1974
- console.error(` CORS origins: ${corsOrigins.join(', ')}`);
1975
- }
1976
- });
1977
- return;
1978
- }
1979
- const eventManager = new EventSubscriptionManager();
1980
- const mqttCreds = tryLoadConfig();
1981
- if (mqttCreds) {
1982
- eventManager.initialize(mqttCreds.token, mqttCreds.secret).catch((err) => {
1983
- console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err));
1984
- });
1985
- }
1986
- const server = createSwitchBotMcpServer({ eventManager });
1987
- const transport = new StdioServerTransport();
1988
- await server.connect(transport);
1989
- let isShuttingDown = false;
1990
- const gracefulShutdown = async () => {
1991
- if (isShuttingDown)
1992
- return;
1993
- isShuttingDown = true;
1994
- console.error('Shutting down...');
1995
- // Force exit after 30s if shutdown hangs (e.g. stuck MQTT disconnect).
1996
- const forceExit = setTimeout(() => {
1997
- console.error('Force exiting after 30s timeout');
1998
- process.exit(1);
1999
- }, 30000);
2000
- forceExit.unref();
2001
- try {
2002
- await eventManager.shutdown();
2003
- }
2004
- catch (err) {
2005
- console.error('Error during shutdown:', err instanceof Error ? err.message : String(err));
2006
- }
2007
- process.exit(0);
2008
- };
2009
- process.on('SIGTERM', gracefulShutdown);
2010
- process.on('SIGINT', gracefulShutdown);
2011
- process.stdin.on('end', gracefulShutdown);
2012
- }
2013
- catch (error) {
2014
- handleError(error);
2015
- }
2016
- });
2017
- }
2018
- //# sourceMappingURL=mcp.js.map