@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.
- package/README.md +30 -1
- package/dist/api/client.js +22 -5
- package/dist/auth.js +0 -1
- package/dist/commands/batch.js +12 -6
- package/dist/commands/cache.js +0 -1
- package/dist/commands/capabilities.js +5 -3
- package/dist/commands/catalog.js +0 -1
- package/dist/commands/completion.js +0 -1
- package/dist/commands/config.js +0 -1
- package/dist/commands/device-meta.js +0 -1
- package/dist/commands/devices.js +2 -2
- package/dist/commands/doctor.js +0 -1
- package/dist/commands/events.js +0 -1
- package/dist/commands/expand.js +0 -1
- package/dist/commands/explain.js +0 -1
- package/dist/commands/history.js +0 -1
- package/dist/commands/mcp.js +334 -18
- package/dist/commands/plan.js +0 -1
- package/dist/commands/quota.js +0 -1
- package/dist/commands/scenes.js +0 -1
- package/dist/commands/schema.js +2 -9
- package/dist/commands/watch.js +0 -1
- package/dist/commands/webhook.js +0 -1
- package/dist/config.js +5 -5
- package/dist/devices/cache.js +0 -1
- package/dist/devices/catalog.js +0 -1
- package/dist/devices/device-meta.js +0 -1
- package/dist/index.js +0 -1
- package/dist/lib/devices.js +22 -18
- package/dist/lib/idempotency.js +72 -0
- package/dist/lib/request-context.js +12 -0
- package/dist/lib/scenes.js +0 -1
- package/dist/logger.js +16 -0
- package/dist/mcp/events-subscription.js +210 -0
- package/dist/mqtt/client.js +184 -0
- package/dist/mqtt/credential.js +12 -0
- package/dist/utils/audit.js +0 -1
- package/dist/utils/filter.js +0 -1
- package/dist/utils/flags.js +0 -1
- package/dist/utils/format.js +0 -1
- package/dist/utils/name-resolver.js +0 -1
- package/dist/utils/output.js +30 -6
- package/dist/utils/quota.js +0 -1
- package/dist/utils/retry.js +0 -1
- package/dist/utils/string.js +0 -1
- package/package.json +6 -2
- package/dist/api/client.d.ts +0 -18
- package/dist/api/client.js.map +0 -1
- package/dist/auth.d.ts +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/commands/batch.d.ts +0 -2
- package/dist/commands/batch.js.map +0 -1
- package/dist/commands/cache.d.ts +0 -2
- package/dist/commands/cache.js.map +0 -1
- package/dist/commands/capabilities.d.ts +0 -2
- package/dist/commands/capabilities.js.map +0 -1
- package/dist/commands/catalog.d.ts +0 -2
- package/dist/commands/catalog.js.map +0 -1
- package/dist/commands/completion.d.ts +0 -2
- package/dist/commands/completion.js.map +0 -1
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/config.js.map +0 -1
- package/dist/commands/device-meta.d.ts +0 -2
- package/dist/commands/device-meta.js.map +0 -1
- package/dist/commands/devices.d.ts +0 -2
- package/dist/commands/devices.js.map +0 -1
- package/dist/commands/doctor.d.ts +0 -2
- package/dist/commands/doctor.js.map +0 -1
- package/dist/commands/events.d.ts +0 -15
- package/dist/commands/events.js.map +0 -1
- package/dist/commands/expand.d.ts +0 -2
- package/dist/commands/expand.js.map +0 -1
- package/dist/commands/explain.d.ts +0 -2
- package/dist/commands/explain.js.map +0 -1
- package/dist/commands/history.d.ts +0 -2
- package/dist/commands/history.js.map +0 -1
- package/dist/commands/mcp.d.ts +0 -4
- package/dist/commands/mcp.js.map +0 -1
- package/dist/commands/plan.d.ts +0 -38
- package/dist/commands/plan.js.map +0 -1
- package/dist/commands/quota.d.ts +0 -2
- package/dist/commands/quota.js.map +0 -1
- package/dist/commands/scenes.d.ts +0 -2
- package/dist/commands/scenes.js.map +0 -1
- package/dist/commands/schema.d.ts +0 -2
- package/dist/commands/schema.js.map +0 -1
- package/dist/commands/watch.d.ts +0 -2
- package/dist/commands/watch.js.map +0 -1
- package/dist/commands/webhook.d.ts +0 -2
- package/dist/commands/webhook.js.map +0 -1
- package/dist/config.d.ts +0 -18
- package/dist/config.js.map +0 -1
- package/dist/devices/cache.d.ts +0 -79
- package/dist/devices/cache.js.map +0 -1
- package/dist/devices/catalog.d.ts +0 -70
- package/dist/devices/catalog.js.map +0 -1
- package/dist/devices/device-meta.d.ts +0 -15
- package/dist/devices/device-meta.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js.map +0 -1
- package/dist/lib/devices.d.ts +0 -144
- package/dist/lib/devices.js.map +0 -1
- package/dist/lib/scenes.d.ts +0 -7
- package/dist/lib/scenes.js.map +0 -1
- package/dist/utils/audit.d.ts +0 -13
- package/dist/utils/audit.js.map +0 -1
- package/dist/utils/filter.d.ts +0 -45
- package/dist/utils/filter.js.map +0 -1
- package/dist/utils/flags.d.ts +0 -52
- package/dist/utils/flags.js.map +0 -1
- package/dist/utils/format.d.ts +0 -9
- package/dist/utils/format.js.map +0 -1
- package/dist/utils/name-resolver.d.ts +0 -17
- package/dist/utils/name-resolver.js.map +0 -1
- package/dist/utils/output.d.ts +0 -23
- package/dist/utils/output.js.map +0 -1
- package/dist/utils/quota.d.ts +0 -50
- package/dist/utils/quota.js.map +0 -1
- package/dist/utils/retry.d.ts +0 -23
- package/dist/utils/retry.js.map +0 -1
- package/dist/utils/string.d.ts +0 -2
- package/dist/utils/string.js.map +0 -1
package/dist/commands/mcp.js
CHANGED
|
@@ -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: '
|
|
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: '
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
package/dist/commands/plan.js
CHANGED
package/dist/commands/quota.js
CHANGED
package/dist/commands/scenes.js
CHANGED
package/dist/commands/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { printJson
|
|
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
|
-
|
|
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
|
package/dist/commands/watch.js
CHANGED
package/dist/commands/webhook.js
CHANGED
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
package/dist/devices/cache.js
CHANGED
package/dist/devices/catalog.js
CHANGED
package/dist/index.js
CHANGED
package/dist/lib/devices.js
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
result: '
|
|
118
|
-
|
|
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
|
-
|
|
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
|