@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.
- package/README.md +1 -0
- 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 +3 -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/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
|
---
|
package/dist/api/client.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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, {
|
|
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
package/dist/commands/batch.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
package/dist/commands/cache.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
89
|
+
});
|
|
89
90
|
});
|
|
90
91
|
}
|
|
91
|
-
//# sourceMappingURL=capabilities.js.map
|
package/dist/commands/catalog.js
CHANGED
package/dist/commands/config.js
CHANGED
package/dist/commands/devices.js
CHANGED
|
@@ -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
|
package/dist/commands/doctor.js
CHANGED
package/dist/commands/events.js
CHANGED
package/dist/commands/expand.js
CHANGED
package/dist/commands/explain.js
CHANGED
package/dist/commands/history.js
CHANGED
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
|