@switchbot/openapi-cli 1.3.2 → 2.0.1

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 +30 -1
  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 +5 -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
@@ -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
@@ -357,4 +357,3 @@ Workflow:
357
357
  process.exit(1);
358
358
  });
359
359
  }
360
- //# sourceMappingURL=plan.js.map
@@ -74,4 +74,3 @@ Examples:
74
74
  }
75
75
  });
76
76
  }
77
- //# sourceMappingURL=quota.js.map
@@ -60,4 +60,3 @@ Example:
60
60
  }
61
61
  });
62
62
  }
63
- //# sourceMappingURL=scenes.js.map
@@ -1,4 +1,4 @@
1
- import { printJson, isJsonMode } from '../utils/output.js';
1
+ import { printJson } from '../utils/output.js';
2
2
  import { getEffectiveCatalog } from '../devices/catalog.js';
3
3
  function toSchemaEntry(e) {
4
4
  return {
@@ -65,13 +65,6 @@ Examples:
65
65
  generatedAt: new Date().toISOString(),
66
66
  types: filtered.map(toSchemaEntry),
67
67
  };
68
- // Always JSON — schema export without JSON would be a category error.
69
- if (isJsonMode()) {
70
- printJson(payload);
71
- }
72
- else {
73
- console.log(JSON.stringify(payload, null, 2));
74
- }
68
+ printJson(payload);
75
69
  });
76
70
  }
77
- //# sourceMappingURL=schema.js.map
@@ -160,4 +160,3 @@ Examples:
160
160
  }
161
161
  });
162
162
  }
163
- //# sourceMappingURL=watch.js.map
@@ -179,4 +179,3 @@ Example:
179
179
  }
180
180
  });
181
181
  }
182
- //# sourceMappingURL=webhook.js.map
package/dist/config.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import { getConfigPath, getProfile } from './utils/flags.js';
4
+ import { getConfigPath } from './utils/flags.js';
5
+ import { getActiveProfile } from './lib/request-context.js';
5
6
  /**
6
7
  * Credential file resolution priority:
7
8
  * 1. --config <path> (absolute override — wins over everything)
8
- * 2. --profile <name> → ~/.switchbot/profiles/<name>.json
9
+ * 2. active profile (ALS request context, else --profile flag) → ~/.switchbot/profiles/<name>.json
9
10
  * 3. default → ~/.switchbot/config.json
10
11
  *
11
12
  * Env SWITCHBOT_TOKEN+SWITCHBOT_SECRET still take priority inside loadConfig.
@@ -14,7 +15,7 @@ export function configFilePath() {
14
15
  const override = getConfigPath();
15
16
  if (override)
16
17
  return path.resolve(override);
17
- const profile = getProfile();
18
+ const profile = getActiveProfile();
18
19
  if (profile) {
19
20
  return path.join(os.homedir(), '.switchbot', 'profiles', `${profile}.json`);
20
21
  }
@@ -40,7 +41,7 @@ export function loadConfig() {
40
41
  }
41
42
  const file = configFilePath();
42
43
  if (!fs.existsSync(file)) {
43
- const profile = getProfile();
44
+ const profile = getActiveProfile();
44
45
  const hint = profile
45
46
  ? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token <token> <secret>`
46
47
  : 'No credentials configured. Run: switchbot config set-token <token> <secret>';
@@ -100,4 +101,3 @@ function maskSecret(secret) {
100
101
  return '****';
101
102
  return secret.slice(0, 2) + '*'.repeat(secret.length - 4) + secret.slice(-2);
102
103
  }
103
- //# sourceMappingURL=config.js.map
@@ -256,4 +256,3 @@ export function describeCache(now = Date.now()) {
256
256
  }
257
257
  return { list, status };
258
258
  }
259
- //# sourceMappingURL=cache.js.map
@@ -658,4 +658,3 @@ export function getEffectiveCatalog() {
658
658
  }
659
659
  return Array.from(byType.values());
660
660
  }
661
- //# sourceMappingURL=catalog.js.map
@@ -54,4 +54,3 @@ export function clearDeviceMeta(deviceId) {
54
54
  delete meta.devices[deviceId];
55
55
  saveDeviceMeta(meta);
56
56
  }
57
- //# sourceMappingURL=device-meta.js.map
package/dist/index.js CHANGED
@@ -115,4 +115,3 @@ catch (err) {
115
115
  }
116
116
  throw err;
117
117
  }
118
- //# sourceMappingURL=index.js.map
@@ -1,4 +1,5 @@
1
1
  import { createClient } from '../api/client.js';
2
+ import { idempotencyCache } from './idempotency.js';
2
3
  import { findCatalogEntry, suggestedActions, getEffectiveCatalog, } from '../devices/catalog.js';
3
4
  import { getCachedDevice, updateCacheFromDeviceList, loadCache, isListCacheFresh, getCachedStatus, setCachedStatus, } from '../devices/cache.js';
4
5
  import { getCacheMode } from '../utils/flags.js';
@@ -85,7 +86,7 @@ export async function fetchDeviceStatus(deviceId, client) {
85
86
  * (JSON-object when applicable), not a raw CLI string — callers should parse
86
87
  * upstream if needed.
87
88
  */
88
- export async function executeCommand(deviceId, cmd, parameter, commandType, client) {
89
+ export async function executeCommand(deviceId, cmd, parameter, commandType, client, options) {
89
90
  const c = client ?? createClient();
90
91
  const body = {
91
92
  command: cmd,
@@ -101,25 +102,29 @@ export async function executeCommand(deviceId, cmd, parameter, commandType, clie
101
102
  commandType,
102
103
  dryRun: isDryRun(),
103
104
  };
104
- try {
105
- const res = await c.post(`/v1.1/devices/${deviceId}/commands`, body);
106
- writeAudit({ ...baseAudit, result: 'ok' });
107
- return res.data.body;
108
- }
109
- catch (err) {
110
- // Dry-run intercepts throw DryRunSignal — still log the intent.
111
- if (err instanceof Error && err.name === 'DryRunSignal') {
105
+ // Wrap in idempotency cache if key is provided
106
+ const execute = async () => {
107
+ try {
108
+ const res = await c.post(`/v1.1/devices/${deviceId}/commands`, body);
112
109
  writeAudit({ ...baseAudit, result: 'ok' });
110
+ return res.data.body;
113
111
  }
114
- else {
115
- writeAudit({
116
- ...baseAudit,
117
- result: 'error',
118
- error: err instanceof Error ? err.message : String(err),
119
- });
112
+ catch (err) {
113
+ // Dry-run intercepts throw DryRunSignal — still log the intent.
114
+ if (err instanceof Error && err.name === 'DryRunSignal') {
115
+ writeAudit({ ...baseAudit, result: 'ok' });
116
+ }
117
+ else {
118
+ writeAudit({
119
+ ...baseAudit,
120
+ result: 'error',
121
+ error: err instanceof Error ? err.message : String(err),
122
+ });
123
+ }
124
+ throw err;
120
125
  }
121
- throw err;
122
- }
126
+ };
127
+ return idempotencyCache.run(options?.idempotencyKey, execute);
123
128
  }
124
129
  /**
125
130
  * Validate a command against the locally-cached device → catalog mapping.
@@ -326,4 +331,3 @@ export function toMcpIrDeviceShape(d) {
326
331
  controlType: d.controlType,
327
332
  };
328
333
  }
329
- //# sourceMappingURL=devices.js.map