@switchbot/openapi-cli 1.3.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +1 -0
  2. package/dist/api/client.js +22 -5
  3. package/dist/auth.js +0 -1
  4. package/dist/commands/batch.js +12 -6
  5. package/dist/commands/cache.js +0 -1
  6. package/dist/commands/capabilities.js +3 -3
  7. package/dist/commands/catalog.js +0 -1
  8. package/dist/commands/completion.js +0 -1
  9. package/dist/commands/config.js +0 -1
  10. package/dist/commands/device-meta.js +0 -1
  11. package/dist/commands/devices.js +2 -2
  12. package/dist/commands/doctor.js +0 -1
  13. package/dist/commands/events.js +0 -1
  14. package/dist/commands/expand.js +0 -1
  15. package/dist/commands/explain.js +0 -1
  16. package/dist/commands/history.js +0 -1
  17. package/dist/commands/mcp.js +334 -18
  18. package/dist/commands/plan.js +0 -1
  19. package/dist/commands/quota.js +0 -1
  20. package/dist/commands/scenes.js +0 -1
  21. package/dist/commands/schema.js +2 -9
  22. package/dist/commands/watch.js +0 -1
  23. package/dist/commands/webhook.js +0 -1
  24. package/dist/config.js +5 -5
  25. package/dist/devices/cache.js +0 -1
  26. package/dist/devices/catalog.js +0 -1
  27. package/dist/devices/device-meta.js +0 -1
  28. package/dist/index.js +0 -1
  29. package/dist/lib/devices.js +22 -18
  30. package/dist/lib/idempotency.js +72 -0
  31. package/dist/lib/request-context.js +12 -0
  32. package/dist/lib/scenes.js +0 -1
  33. package/dist/logger.js +16 -0
  34. package/dist/mcp/events-subscription.js +210 -0
  35. package/dist/mqtt/client.js +184 -0
  36. package/dist/mqtt/credential.js +12 -0
  37. package/dist/utils/audit.js +0 -1
  38. package/dist/utils/filter.js +0 -1
  39. package/dist/utils/flags.js +0 -1
  40. package/dist/utils/format.js +0 -1
  41. package/dist/utils/name-resolver.js +0 -1
  42. package/dist/utils/output.js +30 -6
  43. package/dist/utils/quota.js +0 -1
  44. package/dist/utils/retry.js +0 -1
  45. package/dist/utils/string.js +0 -1
  46. package/package.json +6 -2
  47. package/dist/api/client.d.ts +0 -18
  48. package/dist/api/client.js.map +0 -1
  49. package/dist/auth.d.ts +0 -1
  50. package/dist/auth.js.map +0 -1
  51. package/dist/commands/batch.d.ts +0 -2
  52. package/dist/commands/batch.js.map +0 -1
  53. package/dist/commands/cache.d.ts +0 -2
  54. package/dist/commands/cache.js.map +0 -1
  55. package/dist/commands/capabilities.d.ts +0 -2
  56. package/dist/commands/capabilities.js.map +0 -1
  57. package/dist/commands/catalog.d.ts +0 -2
  58. package/dist/commands/catalog.js.map +0 -1
  59. package/dist/commands/completion.d.ts +0 -2
  60. package/dist/commands/completion.js.map +0 -1
  61. package/dist/commands/config.d.ts +0 -2
  62. package/dist/commands/config.js.map +0 -1
  63. package/dist/commands/device-meta.d.ts +0 -2
  64. package/dist/commands/device-meta.js.map +0 -1
  65. package/dist/commands/devices.d.ts +0 -2
  66. package/dist/commands/devices.js.map +0 -1
  67. package/dist/commands/doctor.d.ts +0 -2
  68. package/dist/commands/doctor.js.map +0 -1
  69. package/dist/commands/events.d.ts +0 -15
  70. package/dist/commands/events.js.map +0 -1
  71. package/dist/commands/expand.d.ts +0 -2
  72. package/dist/commands/expand.js.map +0 -1
  73. package/dist/commands/explain.d.ts +0 -2
  74. package/dist/commands/explain.js.map +0 -1
  75. package/dist/commands/history.d.ts +0 -2
  76. package/dist/commands/history.js.map +0 -1
  77. package/dist/commands/mcp.d.ts +0 -4
  78. package/dist/commands/mcp.js.map +0 -1
  79. package/dist/commands/plan.d.ts +0 -38
  80. package/dist/commands/plan.js.map +0 -1
  81. package/dist/commands/quota.d.ts +0 -2
  82. package/dist/commands/quota.js.map +0 -1
  83. package/dist/commands/scenes.d.ts +0 -2
  84. package/dist/commands/scenes.js.map +0 -1
  85. package/dist/commands/schema.d.ts +0 -2
  86. package/dist/commands/schema.js.map +0 -1
  87. package/dist/commands/watch.d.ts +0 -2
  88. package/dist/commands/watch.js.map +0 -1
  89. package/dist/commands/webhook.d.ts +0 -2
  90. package/dist/commands/webhook.js.map +0 -1
  91. package/dist/config.d.ts +0 -18
  92. package/dist/config.js.map +0 -1
  93. package/dist/devices/cache.d.ts +0 -79
  94. package/dist/devices/cache.js.map +0 -1
  95. package/dist/devices/catalog.d.ts +0 -70
  96. package/dist/devices/catalog.js.map +0 -1
  97. package/dist/devices/device-meta.d.ts +0 -15
  98. package/dist/devices/device-meta.js.map +0 -1
  99. package/dist/index.d.ts +0 -2
  100. package/dist/index.js.map +0 -1
  101. package/dist/lib/devices.d.ts +0 -144
  102. package/dist/lib/devices.js.map +0 -1
  103. package/dist/lib/scenes.d.ts +0 -7
  104. package/dist/lib/scenes.js.map +0 -1
  105. package/dist/utils/audit.d.ts +0 -13
  106. package/dist/utils/audit.js.map +0 -1
  107. package/dist/utils/filter.d.ts +0 -45
  108. package/dist/utils/filter.js.map +0 -1
  109. package/dist/utils/flags.d.ts +0 -52
  110. package/dist/utils/flags.js.map +0 -1
  111. package/dist/utils/format.d.ts +0 -9
  112. package/dist/utils/format.js.map +0 -1
  113. package/dist/utils/name-resolver.d.ts +0 -17
  114. package/dist/utils/name-resolver.js.map +0 -1
  115. package/dist/utils/output.d.ts +0 -23
  116. package/dist/utils/output.js.map +0 -1
  117. package/dist/utils/quota.d.ts +0 -50
  118. package/dist/utils/quota.js.map +0 -1
  119. package/dist/utils/retry.d.ts +0 -23
  120. package/dist/utils/retry.js.map +0 -1
  121. package/dist/utils/string.d.ts +0 -2
  122. package/dist/utils/string.js.map +0 -1
package/README.md CHANGED
@@ -11,6 +11,7 @@ List devices, query live status, send control commands, run scenes, and manage w
11
11
 
12
12
  - **npm package:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli)
13
13
  - **Source code:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli)
14
+ - **Releases / changelog:** [GitHub Releases](https://github.com/OpenWonderLabs/switchbot-openapi-cli/releases)
14
15
  - **Issues / feature requests:** [GitHub Issues](https://github.com/OpenWonderLabs/switchbot-openapi-cli/issues)
15
16
 
16
17
  ---
@@ -79,7 +79,7 @@ export function createClient() {
79
79
  throw error;
80
80
  if (axios.isAxiosError(error)) {
81
81
  if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
82
- throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { retryable: false });
82
+ throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { transient: true, retryable: false });
83
83
  }
84
84
  const status = error.response?.status;
85
85
  const config = error.config;
@@ -105,12 +105,26 @@ export function createClient() {
105
105
  recordRequest(method, url);
106
106
  }
107
107
  if (status === 401) {
108
- throw new ApiError('Authentication failed: invalid token or daily 10,000-request quota exceeded', 401, { retryable: false, hint: 'Run `switchbot config set-token <token> <secret>` to re-enter credentials, or `switchbot quota status` to check today\'s local count.' });
108
+ throw new ApiError('Authentication failed: invalid token or daily 10,000-request quota exceeded', 401, {
109
+ transient: false,
110
+ retryable: false,
111
+ hint: 'Run `switchbot config set-token <token> <secret>` to re-enter credentials, or `switchbot quota status` to check today\'s local count.'
112
+ });
109
113
  }
110
114
  if (status === 429) {
111
- throw new ApiError('Request rate too high: daily 10,000-request quota exceeded (retries exhausted)', 429, { retryable: true, hint: 'Use `switchbot quota status` to see today\'s usage; raise `--retry-on-429 <n>` for more retries.' });
115
+ const retryAfter = error.response?.headers?.['retry-after'];
116
+ const retryAfterMs = nextRetryDelayMs(maxRetries - 1, backoff, retryAfter);
117
+ throw new ApiError('Request rate too high: daily 10,000-request quota exceeded (retries exhausted)', 429, {
118
+ retryable: true,
119
+ transient: true,
120
+ retryAfterMs,
121
+ hint: 'Use `switchbot quota status` to see today\'s usage; raise `--retry-on-429 <n>` for more retries.'
122
+ });
112
123
  }
113
- throw new ApiError(`HTTP ${status ?? '?'}: ${error.message}`, status ?? 0, { retryable: status !== undefined && status >= 500 });
124
+ throw new ApiError(`HTTP ${status ?? '?'}: ${error.message}`, status ?? 0, {
125
+ retryable: status !== undefined && status >= 500,
126
+ transient: status !== undefined && (status >= 500 || status === 0) // 5xx, 0 = connection error
127
+ });
114
128
  }
115
129
  throw error;
116
130
  });
@@ -120,12 +134,15 @@ export class ApiError extends Error {
120
134
  code;
121
135
  retryable;
122
136
  hint;
137
+ retryAfterMs;
138
+ transient;
123
139
  constructor(message, code, meta = {}) {
124
140
  super(message);
125
141
  this.code = code;
126
142
  this.name = 'ApiError';
127
143
  this.retryable = meta.retryable ?? false;
128
144
  this.hint = meta.hint;
145
+ this.retryAfterMs = meta.retryAfterMs;
146
+ this.transient = meta.transient ?? false;
129
147
  }
130
148
  }
131
- //# sourceMappingURL=client.js.map
package/dist/auth.js CHANGED
@@ -18,4 +18,3 @@ export function buildAuthHeaders(token, secret) {
18
18
  'Content-Type': 'application/json',
19
19
  };
20
20
  }
21
- //# sourceMappingURL=auth.js.map
@@ -1,4 +1,4 @@
1
- import { printJson, isJsonMode, handleError } from '../utils/output.js';
1
+ import { printJson, isJsonMode, handleError, buildErrorPayload } from '../utils/output.js';
2
2
  import { fetchDeviceList, executeCommand, isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js';
3
3
  import { createClient } from '../api/client.js';
4
4
  import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js';
@@ -90,6 +90,7 @@ export function registerBatchCommand(devices) {
90
90
  .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
91
91
  .option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', 'command')
92
92
  .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
93
+ .option('--idempotency-key-prefix <prefix>', 'Prefix for idempotency keys (key per device: <prefix>-<deviceId>)')
93
94
  .addHelpText('after', `
94
95
  Targets are resolved in this priority order:
95
96
  1. --ids when present (explicit deviceIds)
@@ -214,7 +215,12 @@ Examples:
214
215
  const startedAt = Date.now();
215
216
  const outcomes = await runPool(resolved.ids, concurrency, async (id) => {
216
217
  try {
217
- const result = await executeCommand(id, cmd, parsedParam, effectiveType, getClient());
218
+ const idempotencyKey = options.idempotencyKeyPrefix
219
+ ? `${options.idempotencyKeyPrefix}-${id}`
220
+ : undefined;
221
+ const result = await executeCommand(id, cmd, parsedParam, effectiveType, getClient(), {
222
+ idempotencyKey,
223
+ });
218
224
  if (!isJsonMode()) {
219
225
  console.log(`✓ ${id}: ${cmd}`);
220
226
  }
@@ -226,11 +232,11 @@ Examples:
226
232
  if (err instanceof DryRunSignal) {
227
233
  return { ok: 'dry-run', deviceId: id };
228
234
  }
229
- const message = err instanceof Error ? err.message : String(err);
235
+ const errorPayload = buildErrorPayload(err);
230
236
  if (!isJsonMode()) {
231
- console.error(`✗ ${id}: ${message}`);
237
+ console.error(`✗ ${id}: ${errorPayload.message}`);
232
238
  }
233
- return { ok: false, deviceId: id, error: message };
239
+ return { ok: false, deviceId: id, error: errorPayload };
234
240
  }
235
241
  });
236
242
  const succeeded = outcomes.filter((o) => o.ok === true);
@@ -245,6 +251,7 @@ Examples:
245
251
  failed: failed.length,
246
252
  skipped: dryRunned.length,
247
253
  durationMs: Date.now() - startedAt,
254
+ schemaVersion: '1.1',
248
255
  ...(dryRun ? { dryRun: true } : {}),
249
256
  },
250
257
  };
@@ -259,4 +266,3 @@ Examples:
259
266
  process.exit(1);
260
267
  });
261
268
  }
262
- //# sourceMappingURL=batch.js.map
@@ -105,4 +105,3 @@ Examples:
105
105
  }
106
106
  });
107
107
  }
108
- //# sourceMappingURL=cache.js.map
@@ -1,4 +1,5 @@
1
1
  import { getEffectiveCatalog } from '../devices/catalog.js';
2
+ import { printJson } from '../utils/output.js';
2
3
  const IDENTITY = {
3
4
  product: 'SwitchBot',
4
5
  domain: 'IoT smart home device control',
@@ -55,7 +56,7 @@ export function registerCapabilitiesCommand(program) {
55
56
  description: opt.description,
56
57
  }));
57
58
  const roles = [...new Set(catalog.map((e) => e.role ?? 'other'))].sort();
58
- console.log(JSON.stringify({
59
+ printJson({
59
60
  version: program.version(),
60
61
  generatedAt: new Date().toISOString(),
61
62
  identity: IDENTITY,
@@ -85,7 +86,6 @@ export function registerCapabilitiesCommand(program) {
85
86
  destructiveCommandCount: catalog.reduce((n, e) => n + e.commands.filter((c) => c.destructive).length, 0),
86
87
  readOnlyTypeCount: catalog.filter((e) => e.readOnly).length,
87
88
  },
88
- }, null, 2));
89
+ });
89
90
  });
90
91
  }
91
- //# sourceMappingURL=capabilities.js.map
@@ -288,4 +288,3 @@ function renderEntry(entry) {
288
288
  console.log(' ' + entry.statusFields.join(', '));
289
289
  }
290
290
  }
291
- //# sourceMappingURL=catalog.js.map
@@ -256,4 +256,3 @@ source it directly:
256
256
  }
257
257
  });
258
258
  }
259
- //# sourceMappingURL=completion.js.map
@@ -147,4 +147,3 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
147
147
  console.log(p);
148
148
  });
149
149
  }
150
- //# sourceMappingURL=config.js.map
@@ -139,4 +139,3 @@ export function registerDevicesMetaCommand(devices) {
139
139
  }
140
140
  });
141
141
  }
142
- //# sourceMappingURL=device-meta.js.map
@@ -189,6 +189,7 @@ Examples:
189
189
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
190
190
  .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', 'command')
191
191
  .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
192
+ .option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)')
192
193
  .addHelpText('after', `
193
194
  ────────────────────────────────────────────────────────────────────────
194
195
  For the full list of commands a specific device supports — and their
@@ -298,7 +299,7 @@ Examples:
298
299
  // keep as string
299
300
  }
300
301
  }
301
- const body = await executeCommand(deviceId, cmd, parsedParam, options.type);
302
+ const body = await executeCommand(deviceId, cmd, parsedParam, options.type, undefined, { idempotencyKey: options.idempotencyKey });
302
303
  const isIr = getCachedDevice(deviceId)?.category === 'ir';
303
304
  if (isJsonMode()) {
304
305
  const result = { ok: true, command: cmd, deviceId };
@@ -554,4 +555,3 @@ function renderCatalogEntry(entry) {
554
555
  console.log(' ' + entry.statusFields.join(', '));
555
556
  }
556
557
  }
557
- //# sourceMappingURL=devices.js.map
@@ -144,4 +144,3 @@ Examples:
144
144
  process.exit(1);
145
145
  });
146
146
  }
147
- //# sourceMappingURL=doctor.js.map
@@ -185,4 +185,3 @@ Examples:
185
185
  }
186
186
  });
187
187
  }
188
- //# sourceMappingURL=events.js.map
@@ -192,4 +192,3 @@ Examples:
192
192
  }
193
193
  });
194
194
  }
195
- //# sourceMappingURL=expand.js.map
@@ -134,4 +134,3 @@ function printHuman(r) {
134
134
  }
135
135
  }
136
136
  }
137
- //# sourceMappingURL=explain.js.map
@@ -101,4 +101,3 @@ Examples:
101
101
  }
102
102
  });
103
103
  }
104
- //# sourceMappingURL=history.js.map
@@ -7,6 +7,13 @@ import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, val
7
7
  import { fetchScenes, executeScene } from '../lib/scenes.js';
8
8
  import { findCatalogEntry } from '../devices/catalog.js';
9
9
  import { getCachedDevice } from '../devices/cache.js';
10
+ import { EventSubscriptionManager } from '../mcp/events-subscription.js';
11
+ import { todayUsage } from '../utils/quota.js';
12
+ import { describeCache } from '../devices/cache.js';
13
+ import { withRequestContext } from '../lib/request-context.js';
14
+ import { profileFilePath } from '../config.js';
15
+ import { getMqttConfig } from '../mqtt/credential.js';
16
+ import fs from 'node:fs';
10
17
  function mcpError(kind, code, message, options) {
11
18
  const obj = { code, kind, message };
12
19
  if (options?.hint)
@@ -20,12 +27,13 @@ function mcpError(kind, code, message, options) {
20
27
  content: [{ type: 'text', text: JSON.stringify({ error: obj }, null, 2) }],
21
28
  };
22
29
  }
23
- export function createSwitchBotMcpServer() {
30
+ export function createSwitchBotMcpServer(options) {
31
+ const eventManager = options?.eventManager;
24
32
  const server = new McpServer({
25
33
  name: 'switchbot',
26
- version: '1.4.0',
34
+ version: '2.0.0',
27
35
  }, {
28
- capabilities: { tools: {} },
36
+ capabilities: { tools: {}, resources: {} },
29
37
  instructions: `SwitchBot is an IoT smart home brand by Wonderlabs, Inc. This MCP server controls physical devices \
30
38
  (Bot, Curtain, Smart Lock, Color Bulb, Meter, Plug, Robot Vacuum, etc.) and IR remotes \
31
39
  (TV, AC, Set Top Box, etc.) via the SwitchBot Cloud API v1.1.
@@ -49,7 +57,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
49
57
  // ---- list_devices ---------------------------------------------------------
50
58
  server.registerTool('list_devices', {
51
59
  title: 'List all devices on the account',
52
- description: 'Fetch the inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local cache.',
60
+ 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.',
53
61
  inputSchema: {},
54
62
  outputSchema: {
55
63
  deviceList: z.array(z.object({
@@ -106,7 +114,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
106
114
  // ---- send_command ---------------------------------------------------------
107
115
  server.registerTool('send_command', {
108
116
  title: 'Send a control command to a device',
109
- description: 'Send a control command (turnOn, setColor, startClean, unlock, ...) to a device. Destructive commands (unlock, garage open, keypad createKey) require confirm:true; otherwise they are rejected.',
117
+ description: 'Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands (Smart Lock unlock, Garage Door open, Keypad createKey/deleteKey) require confirm:true to proceed; otherwise rejected. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.',
110
118
  inputSchema: {
111
119
  deviceId: z.string().describe('Device ID from list_devices'),
112
120
  command: z.string().describe('Command name, case-sensitive (e.g. turnOn, setColor, unlock)'),
@@ -295,6 +303,115 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
295
303
  throw err;
296
304
  }
297
305
  });
306
+ // ---- account_overview ---------------------------------------------------
307
+ server.registerTool('account_overview', {
308
+ title: 'Bootstrap account overview',
309
+ 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.',
310
+ inputSchema: {},
311
+ outputSchema: {
312
+ version: z.string(),
313
+ schemaVersion: z.string(),
314
+ devices: z.array(z.object({
315
+ deviceId: z.string(),
316
+ deviceName: z.string(),
317
+ deviceType: z.string().optional(),
318
+ }).passthrough()).describe('All physical devices'),
319
+ infraredRemotes: z.array(z.object({
320
+ deviceId: z.string(),
321
+ deviceName: z.string(),
322
+ remoteType: z.string(),
323
+ }).passthrough()).describe('All IR remotes'),
324
+ scenes: z.array(z.object({
325
+ sceneId: z.string(),
326
+ sceneName: z.string(),
327
+ }).passthrough()).describe('All manual scenes'),
328
+ quota: z.object({
329
+ date: z.string(),
330
+ total: z.number(),
331
+ remaining: z.number(),
332
+ endpoints: z.record(z.string(), z.number()).optional(),
333
+ }).describe('Today\'s quota usage'),
334
+ cache: z.object({
335
+ list: z.object({
336
+ path: z.string(),
337
+ exists: z.boolean(),
338
+ lastUpdated: z.string().optional(),
339
+ ageMs: z.number().optional(),
340
+ deviceCount: z.number().optional(),
341
+ }),
342
+ status: z.object({
343
+ path: z.string(),
344
+ exists: z.boolean(),
345
+ entryCount: z.number(),
346
+ oldestFetchedAt: z.string().optional(),
347
+ newestFetchedAt: z.string().optional(),
348
+ }),
349
+ }).describe('Cache status'),
350
+ mqtt: z.object({
351
+ state: z.string(),
352
+ subscribers: z.number(),
353
+ }).optional().describe('MQTT connection state (HTTP mode only)'),
354
+ },
355
+ }, async () => {
356
+ const deviceList = await fetchDeviceList();
357
+ const sceneList = await fetchScenes();
358
+ const cacheInfo = describeCache();
359
+ const quota = todayUsage();
360
+ const overview = {
361
+ version: '2.0.0',
362
+ schemaVersion: '1.1',
363
+ devices: deviceList.deviceList.map(toMcpDeviceListShape),
364
+ infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
365
+ scenes: sceneList.map((s) => ({
366
+ sceneId: s.sceneId,
367
+ sceneName: s.sceneName,
368
+ })),
369
+ quota: {
370
+ date: quota.date,
371
+ total: quota.total,
372
+ remaining: quota.remaining,
373
+ endpoints: quota.endpoints,
374
+ },
375
+ cache: {
376
+ list: cacheInfo.list,
377
+ status: cacheInfo.status,
378
+ },
379
+ ...(eventManager ? {
380
+ mqtt: {
381
+ state: eventManager.getState(),
382
+ subscribers: eventManager.getSubscriberCount(),
383
+ },
384
+ } : {}),
385
+ };
386
+ return {
387
+ content: [{
388
+ type: 'text',
389
+ text: JSON.stringify(overview, null, 2),
390
+ }],
391
+ structuredContent: overview,
392
+ };
393
+ });
394
+ // switchbot://events resource — snapshot of recent shadow events from the ring buffer.
395
+ // Returns up to 100 recent events. When MQTT is disabled, returns an empty list with a state note.
396
+ // URI: switchbot://events (optional query: ?filter=<expression> ?limit=<n>)
397
+ if (eventManager) {
398
+ server.registerResource('events', 'switchbot://events', {
399
+ title: 'SwitchBot real-time shadow events',
400
+ description: 'Recent device shadow-update events received via MQTT. Returns a JSON snapshot of the ring buffer. ' +
401
+ 'State is "disabled" when MQTT credentials are not configured (set SWITCHBOT_MQTT_HOST / USERNAME / PASSWORD).',
402
+ mimeType: 'application/json',
403
+ }, (_uri) => {
404
+ const state = eventManager.getState();
405
+ const events = state !== 'disabled' ? eventManager.getRecentEvents(100) : [];
406
+ return {
407
+ contents: [{
408
+ uri: 'switchbot://events',
409
+ mimeType: 'application/json',
410
+ text: JSON.stringify({ state, count: events.length, events }, null, 2),
411
+ }],
412
+ };
413
+ });
414
+ }
298
415
  return server;
299
416
  }
300
417
  export function registerMcpCommand(program) {
@@ -333,6 +450,10 @@ Inspect locally:
333
450
  .command('serve')
334
451
  .description('Start the MCP server on stdio (default) or HTTP (--port)')
335
452
  .option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)')
453
+ .option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', '127.0.0.1')
454
+ .option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)')
455
+ .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)')
456
+ .option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', '60')
336
457
  .action(async (options) => {
337
458
  try {
338
459
  if (options.port) {
@@ -347,30 +468,226 @@ Inspect locally:
347
468
  }
348
469
  process.exit(2);
349
470
  }
471
+ const bind = options.bind ?? '127.0.0.1';
472
+ const authToken = options.authToken ?? process.env.SWITCHBOT_MCP_TOKEN;
473
+ const corsOrigins = Array.isArray(options.corsOrigin) ? options.corsOrigin : (options.corsOrigin ? [options.corsOrigin] : []);
474
+ const rateLimit = Math.max(1, Number(options.rateLimit) || 60);
475
+ // Guard: refuse to bind non-localhost without auth
476
+ const isLocalhost = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1';
477
+ if (!isLocalhost && !authToken) {
478
+ const msg = 'Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).';
479
+ if (isJsonMode()) {
480
+ console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
481
+ }
482
+ else {
483
+ console.error(msg);
484
+ }
485
+ process.exit(2);
486
+ }
350
487
  const { createServer } = await import('node:http');
488
+ const rateLimitMap = new Map();
489
+ // Initialize shared EventSubscriptionManager for event streaming.
490
+ // If MQTT creds are present, connect in the background so the HTTP server
491
+ // starts immediately; /ready reflects the real state.
492
+ const eventManager = new EventSubscriptionManager();
493
+ const mqttConfig = getMqttConfig();
494
+ if (mqttConfig) {
495
+ eventManager.initialize(mqttConfig).catch((err) => {
496
+ console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err));
497
+ });
498
+ }
499
+ else {
500
+ console.error('MQTT disabled: set SWITCHBOT_MQTT_HOST, SWITCHBOT_MQTT_USERNAME, SWITCHBOT_MQTT_PASSWORD to enable real-time events.');
501
+ }
502
+ // Helper: constant-time token comparison
503
+ const tokenMatch = (provided) => {
504
+ if (!authToken)
505
+ return true; // No token configured, allow all
506
+ if (!provided)
507
+ return false;
508
+ const expected = authToken;
509
+ let match = true;
510
+ for (let i = 0; i < Math.max(expected.length, provided.length); i++) {
511
+ if ((expected[i] ?? '\0') !== (provided[i] ?? '\0'))
512
+ match = false;
513
+ }
514
+ return match;
515
+ };
516
+ // Helper: rate limit check
517
+ const checkRateLimit = (profile) => {
518
+ const now = Date.now();
519
+ const bucket = rateLimitMap.get(profile);
520
+ if (!bucket || now >= bucket.resetAt) {
521
+ rateLimitMap.set(profile, { count: 1, resetAt: now + 60000 });
522
+ return true;
523
+ }
524
+ bucket.count++;
525
+ return bucket.count <= rateLimit;
526
+ };
351
527
  const httpServer = createServer(async (req, res) => {
528
+ // Health and metrics routes (no auth required)
529
+ if (req.url === '/healthz' && req.method === 'GET') {
530
+ res.writeHead(200, { 'Content-Type': 'application/json' });
531
+ res.end(JSON.stringify({
532
+ ok: true,
533
+ version: '2.0.0',
534
+ pid: process.pid,
535
+ uptimeSec: Math.floor(process.uptime()),
536
+ }));
537
+ return;
538
+ }
539
+ if (req.url === '/ready' && req.method === 'GET') {
540
+ const state = eventManager.getState();
541
+ const ready = state !== 'failed' && state !== 'disabled';
542
+ const status = ready ? 200 : 503;
543
+ const body = { ready, version: '2.0.0', mqtt: state };
544
+ if (!ready)
545
+ body.reason = state === 'disabled' ? 'mqtt disabled' : 'mqtt failed';
546
+ res.writeHead(status, { 'Content-Type': 'application/json' });
547
+ res.end(JSON.stringify(body));
548
+ return;
549
+ }
550
+ if (req.url === '/metrics' && req.method === 'GET') {
551
+ const mqttState = eventManager.getState();
552
+ const metrics = `# HELP switchbot_mqtt_connected MQTT connection status (0=disconnected, 1=connected)
553
+ # TYPE switchbot_mqtt_connected gauge
554
+ switchbot_mqtt_connected ${mqttState === 'connected' ? 1 : 0}
555
+
556
+ # HELP switchbot_mqtt_state Current MQTT state (1 for the active state, 0 otherwise)
557
+ # TYPE switchbot_mqtt_state gauge
558
+ switchbot_mqtt_state{state="disabled"} ${mqttState === 'disabled' ? 1 : 0}
559
+ switchbot_mqtt_state{state="connecting"} ${mqttState === 'connecting' ? 1 : 0}
560
+ switchbot_mqtt_state{state="connected"} ${mqttState === 'connected' ? 1 : 0}
561
+ switchbot_mqtt_state{state="reconnecting"} ${mqttState === 'reconnecting' ? 1 : 0}
562
+ switchbot_mqtt_state{state="failed"} ${mqttState === 'failed' ? 1 : 0}
563
+
564
+ # HELP switchbot_mqtt_subscribers Number of active event subscribers
565
+ # TYPE switchbot_mqtt_subscribers gauge
566
+ switchbot_mqtt_subscribers ${eventManager.getSubscriberCount()}
567
+
568
+ # HELP process_uptime_seconds Process uptime in seconds
569
+ # TYPE process_uptime_seconds gauge
570
+ process_uptime_seconds ${Math.floor(process.uptime())}
571
+ `;
572
+ res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' });
573
+ res.end(metrics);
574
+ return;
575
+ }
576
+ // Extract profile from header or query string
577
+ const headerProfile = req.headers['x-switchbot-profile'];
578
+ const profileHeader = Array.isArray(headerProfile) ? headerProfile[0] : headerProfile;
579
+ let profileQuery;
580
+ try {
581
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
582
+ profileQuery = url.searchParams.get('profile') ?? undefined;
583
+ }
584
+ catch { /* ignore */ }
585
+ const profile = profileHeader || profileQuery;
586
+ // CORS preflight
587
+ if (req.method === 'OPTIONS') {
588
+ if (corsOrigins.length > 0) {
589
+ const origin = req.headers.origin;
590
+ if (origin && corsOrigins.includes(origin)) {
591
+ res.writeHead(200, {
592
+ 'Access-Control-Allow-Origin': origin,
593
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
594
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
595
+ });
596
+ res.end();
597
+ return;
598
+ }
599
+ }
600
+ res.writeHead(204);
601
+ res.end();
602
+ return;
603
+ }
604
+ // Rate limit check
605
+ if (!checkRateLimit(profile ?? 'default')) {
606
+ res.writeHead(429, { 'Content-Type': 'application/json' });
607
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Rate limit exceeded' }, id: null }));
608
+ return;
609
+ }
610
+ // Auth check
611
+ const authHeader = req.headers.authorization;
612
+ const [scheme, token] = (authHeader ?? '').split(' ');
613
+ if (authToken && (scheme !== 'Bearer' || !tokenMatch(token))) {
614
+ res.writeHead(401, { 'Content-Type': 'application/json' });
615
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null }));
616
+ return;
617
+ }
618
+ // CORS headers for allowed origins
619
+ if (corsOrigins.length > 0) {
620
+ const origin = req.headers.origin;
621
+ if (origin && corsOrigins.includes(origin)) {
622
+ res.setHeader('Access-Control-Allow-Origin', origin);
623
+ }
624
+ }
625
+ // Reject unknown profiles early: avoids confusing downstream credential
626
+ // errors and protects against probing for valid profile names.
627
+ if (profile) {
628
+ const envCredsPresent = !!(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
629
+ if (!envCredsPresent && !fs.existsSync(profileFilePath(profile))) {
630
+ res.writeHead(401, { 'Content-Type': 'application/json' });
631
+ res.end(JSON.stringify({
632
+ jsonrpc: '2.0',
633
+ error: { code: -32001, message: `Unknown profile: ${profile}` },
634
+ id: null,
635
+ }));
636
+ return;
637
+ }
638
+ }
352
639
  // Stateless mode: fresh transport+server per request (SDK requirement).
353
640
  const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
354
- const reqServer = createSwitchBotMcpServer();
641
+ const reqServer = createSwitchBotMcpServer({ eventManager });
355
642
  // Register cleanup before any async work so it fires on both normal
356
643
  // close and error-path close (after the 500 response ends).
357
644
  res.on('close', () => {
358
645
  reqTransport.close();
359
646
  reqServer.close();
360
647
  });
361
- try {
362
- await reqServer.connect(reqTransport);
363
- await reqTransport.handleRequest(req, res);
364
- }
365
- catch (err) {
366
- if (!res.headersSent) {
367
- res.writeHead(500, { 'Content-Type': 'application/json' });
368
- res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null }));
648
+ // Route per-request credentials via AsyncLocalStorage so loadConfig()
649
+ // picks up this request's profile instead of the process-global flag.
650
+ await withRequestContext({ profile: profile ?? undefined }, async () => {
651
+ try {
652
+ await reqServer.connect(reqTransport);
653
+ await reqTransport.handleRequest(req, res);
369
654
  }
370
- }
655
+ catch (err) {
656
+ if (!res.headersSent) {
657
+ res.writeHead(500, { 'Content-Type': 'application/json' });
658
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null }));
659
+ }
660
+ }
661
+ });
371
662
  });
372
- httpServer.listen(port, () => {
373
- console.error(`SwitchBot MCP server listening on http://localhost:${port}/mcp`);
663
+ // Graceful shutdown
664
+ let isShuttingDown = false;
665
+ const gracefulShutdown = async () => {
666
+ if (isShuttingDown)
667
+ return;
668
+ isShuttingDown = true;
669
+ console.error('Shutting down...');
670
+ await eventManager.shutdown();
671
+ httpServer.close(() => {
672
+ console.error('Server closed');
673
+ process.exit(0);
674
+ });
675
+ // Force exit after 30s
676
+ setTimeout(() => {
677
+ console.error('Force exiting after 30s timeout');
678
+ process.exit(1);
679
+ }, 30000);
680
+ };
681
+ process.on('SIGTERM', gracefulShutdown);
682
+ process.on('SIGINT', gracefulShutdown);
683
+ httpServer.listen(port, bind, () => {
684
+ console.error(`SwitchBot MCP server listening on http://${bind}:${port}/mcp`);
685
+ if (authToken) {
686
+ console.error(' Authentication: required (Bearer token)');
687
+ }
688
+ if (corsOrigins.length > 0) {
689
+ console.error(` CORS origins: ${corsOrigins.join(', ')}`);
690
+ }
374
691
  });
375
692
  return;
376
693
  }
@@ -383,4 +700,3 @@ Inspect locally:
383
700
  }
384
701
  });
385
702
  }
386
- //# sourceMappingURL=mcp.js.map