@switchbot/openapi-cli 2.4.0 → 2.5.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 +45 -4
- package/dist/commands/agent-bootstrap.js +6 -0
- package/dist/commands/batch.js +65 -25
- package/dist/commands/cache.js +18 -1
- package/dist/commands/capabilities.js +8 -0
- package/dist/commands/config.js +6 -6
- package/dist/commands/device-meta.js +18 -1
- package/dist/commands/devices.js +125 -53
- package/dist/commands/events.js +70 -32
- package/dist/commands/expand.js +7 -5
- package/dist/commands/history.js +124 -24
- package/dist/commands/mcp.js +178 -43
- package/dist/commands/plan.js +12 -2
- package/dist/commands/scenes.js +52 -1
- package/dist/commands/schema.js +6 -0
- package/dist/commands/watch.js +7 -0
- package/dist/devices/cache.js +61 -26
- package/dist/devices/history-agg.js +138 -0
- package/dist/devices/history-query.js +1 -1
- package/dist/index.js +7 -0
- package/dist/mcp/device-history.js +20 -9
- package/dist/utils/arg-parsers.js +2 -1
- package/dist/utils/audit.js +1 -1
- package/dist/utils/filter.js +102 -39
- package/dist/utils/flags.js +3 -1
- package/dist/utils/format.js +11 -3
- package/dist/utils/name-resolver.js +11 -3
- package/dist/utils/output.js +37 -6
- package/dist/version.js +4 -0
- package/package.json +1 -1
package/dist/commands/history.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
4
|
-
import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
4
|
+
import { printJson, isJsonMode, handleError, UsageError, emitJsonError } from '../utils/output.js';
|
|
5
5
|
import { readAudit, verifyAudit } from '../utils/audit.js';
|
|
6
6
|
import { executeCommand } from '../lib/devices.js';
|
|
7
7
|
import { queryDeviceHistory, queryDeviceHistoryStats, } from '../devices/history-query.js';
|
|
8
|
+
import { aggregateDeviceHistory, ALL_AGG_FNS, } from '../devices/history-agg.js';
|
|
8
9
|
const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
|
|
9
10
|
export function registerHistoryCommand(program) {
|
|
10
11
|
const history = program
|
|
@@ -71,7 +72,7 @@ Examples:
|
|
|
71
72
|
if (!Number.isInteger(idx) || idx < 1 || idx > entries.length) {
|
|
72
73
|
const msg = `Invalid index ${indexArg}. Log has ${entries.length} entries.`;
|
|
73
74
|
if (isJsonMode()) {
|
|
74
|
-
|
|
75
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
75
76
|
}
|
|
76
77
|
else {
|
|
77
78
|
console.error(msg);
|
|
@@ -82,7 +83,7 @@ Examples:
|
|
|
82
83
|
if (entry.kind !== 'command') {
|
|
83
84
|
const msg = `Entry ${idx} is not a command (kind=${entry.kind}).`;
|
|
84
85
|
if (isJsonMode()) {
|
|
85
|
-
|
|
86
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
86
87
|
}
|
|
87
88
|
else {
|
|
88
89
|
console.error(msg);
|
|
@@ -186,8 +187,8 @@ Examples:
|
|
|
186
187
|
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
|
|
187
188
|
.addHelpText('after', `
|
|
188
189
|
See docs/audit-log.md for the audit log format. Exit code:
|
|
189
|
-
0 every line parses and carries the current auditVersion
|
|
190
|
-
1 one or more lines are malformed
|
|
190
|
+
0 every line parses and carries the current auditVersion, or file is missing (warn)
|
|
191
|
+
1 one or more lines are malformed or schema drift detected
|
|
191
192
|
2 (usage) — not emitted by this subcommand
|
|
192
193
|
|
|
193
194
|
Examples:
|
|
@@ -197,30 +198,129 @@ Examples:
|
|
|
197
198
|
.action((options) => {
|
|
198
199
|
const file = options.file ?? DEFAULT_AUDIT;
|
|
199
200
|
const report = verifyAudit(file);
|
|
201
|
+
// Determine status and exit code
|
|
202
|
+
let status = 'ok';
|
|
203
|
+
let exitCode = 0;
|
|
204
|
+
if (report.fileMissing) {
|
|
205
|
+
status = 'warn';
|
|
206
|
+
}
|
|
207
|
+
else if (report.malformedLines > 0 || report.unversionedEntries > 0) {
|
|
208
|
+
status = 'fail';
|
|
209
|
+
exitCode = 1;
|
|
210
|
+
}
|
|
200
211
|
if (isJsonMode()) {
|
|
201
|
-
|
|
212
|
+
const output = {
|
|
213
|
+
status,
|
|
214
|
+
fileMissing: report.fileMissing === true,
|
|
215
|
+
parsed: report.parsedLines,
|
|
216
|
+
malformed: report.malformedLines,
|
|
217
|
+
unversioned: report.unversionedEntries,
|
|
218
|
+
message: report.fileMissing
|
|
219
|
+
? 'Audit log file not found (fresh install)'
|
|
220
|
+
: report.malformedLines > 0 || report.unversionedEntries > 0
|
|
221
|
+
? 'Audit log has malformed or unversioned entries'
|
|
222
|
+
: 'Audit log is valid',
|
|
223
|
+
};
|
|
224
|
+
printJson(output);
|
|
202
225
|
}
|
|
203
226
|
else {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
.
|
|
210
|
-
.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
227
|
+
if (report.fileMissing) {
|
|
228
|
+
console.log(`Audit log: ${report.file} (missing — fresh install)`);
|
|
229
|
+
console.log(`Status: ✓ warn (expected for new accounts)`);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
console.log(`Audit log: ${report.file}`);
|
|
233
|
+
console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`);
|
|
234
|
+
console.log(`Malformed: ${report.malformedLines}`);
|
|
235
|
+
console.log(`Unversioned: ${report.unversionedEntries}`);
|
|
236
|
+
const versions = Object.entries(report.versionCounts)
|
|
237
|
+
.map(([v, n]) => `${v}:${n}`)
|
|
238
|
+
.join(', ');
|
|
239
|
+
console.log(`Version counts: ${versions || '—'}`);
|
|
240
|
+
if (report.earliest)
|
|
241
|
+
console.log(`Earliest: ${report.earliest}`);
|
|
242
|
+
if (report.latest)
|
|
243
|
+
console.log(`Latest: ${report.latest}`);
|
|
244
|
+
if (report.problems.length > 0) {
|
|
245
|
+
console.log('\nProblems:');
|
|
246
|
+
for (const p of report.problems) {
|
|
247
|
+
console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
process.exit(exitCode);
|
|
253
|
+
});
|
|
254
|
+
history
|
|
255
|
+
.command('aggregate')
|
|
256
|
+
.description('Aggregate time-ranged device history metrics into buckets')
|
|
257
|
+
.argument('<deviceId>', 'Device ID to aggregate')
|
|
258
|
+
.option('--since <duration>', 'Relative window ending now, e.g. "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
|
|
259
|
+
.option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
|
|
260
|
+
.option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
|
|
261
|
+
.requiredOption('--metric <name>', 'Payload field to aggregate (repeat for multiple; required)', (v, acc = []) => acc.concat(v))
|
|
262
|
+
.option('--agg <csv>', 'Comma-separated aggregation functions (count,min,max,avg,sum,p50,p95)', stringArg('--agg'))
|
|
263
|
+
.option('--bucket <duration>', 'Bucket width, e.g. "15m", "1h", "1d"', stringArg('--bucket'))
|
|
264
|
+
.option('--max-bucket-samples <n>', 'Max samples per bucket for quantiles (1–100000)', intArg('--max-bucket-samples', { min: 1, max: 100_000 }))
|
|
265
|
+
.action(async (deviceId, options) => {
|
|
266
|
+
const metrics = options.metric ?? [];
|
|
267
|
+
if (options.since && (options.from || options.to)) {
|
|
268
|
+
handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
|
|
269
|
+
}
|
|
270
|
+
let aggs;
|
|
271
|
+
if (options.agg !== undefined) {
|
|
272
|
+
const parts = options.agg.split(',').map((s) => s.trim()).filter(Boolean);
|
|
273
|
+
const unknown = parts.filter((p) => !ALL_AGG_FNS.includes(p));
|
|
274
|
+
if (unknown.length > 0) {
|
|
275
|
+
handleError(new UsageError(`Unknown aggregation function(s): ${unknown.join(', ')}. Legal values: ${ALL_AGG_FNS.join(', ')}.`));
|
|
276
|
+
}
|
|
277
|
+
aggs = parts;
|
|
278
|
+
}
|
|
279
|
+
const aggOpts = {
|
|
280
|
+
metrics,
|
|
281
|
+
aggs,
|
|
282
|
+
since: options.since,
|
|
283
|
+
from: options.from,
|
|
284
|
+
to: options.to,
|
|
285
|
+
bucket: options.bucket,
|
|
286
|
+
maxBucketSamples: options.maxBucketSamples !== undefined ? Number(options.maxBucketSamples) : undefined,
|
|
287
|
+
};
|
|
288
|
+
try {
|
|
289
|
+
const res = await aggregateDeviceHistory(deviceId, aggOpts);
|
|
290
|
+
if (isJsonMode()) {
|
|
291
|
+
printJson(res);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (res.buckets.length === 0) {
|
|
295
|
+
console.log(`(no history records for ${deviceId} in requested range)`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const aggCols = res.aggs;
|
|
299
|
+
const cols = ['t', ...res.metrics.flatMap((m) => aggCols.map((a) => `${m}.${a}`))];
|
|
300
|
+
console.log(cols.join('\t'));
|
|
301
|
+
for (const bkt of res.buckets) {
|
|
302
|
+
const row = cols.map((col) => {
|
|
303
|
+
if (col === 't')
|
|
304
|
+
return bkt.t;
|
|
305
|
+
const [metric, agg] = col.split('.');
|
|
306
|
+
const val = bkt.metrics[metric]?.[agg];
|
|
307
|
+
return val !== undefined ? String(val) : '\u2014';
|
|
308
|
+
});
|
|
309
|
+
console.log(row.join('\t'));
|
|
310
|
+
}
|
|
311
|
+
if (res.partial) {
|
|
312
|
+
for (const note of res.notes) {
|
|
313
|
+
console.error('note: ' + note);
|
|
220
314
|
}
|
|
221
315
|
}
|
|
222
316
|
}
|
|
223
|
-
|
|
224
|
-
|
|
317
|
+
catch (err) {
|
|
318
|
+
if (err instanceof Error) {
|
|
319
|
+
if (/bucket/i.test(err.message) || /--since/i.test(err.message) || /--from/i.test(err.message) || /--to/i.test(err.message)) {
|
|
320
|
+
handleError(new UsageError(err.message));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
handleError(err);
|
|
324
|
+
}
|
|
225
325
|
});
|
|
226
326
|
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -3,7 +3,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
3
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { intArg, stringArg } from '../utils/arg-parsers.js';
|
|
6
|
-
import { handleError, isJsonMode } from '../utils/output.js';
|
|
6
|
+
import { handleError, isJsonMode, buildErrorPayload, emitJsonError } from '../utils/output.js';
|
|
7
|
+
import { VERSION } from '../version.js';
|
|
7
8
|
import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
|
|
8
9
|
import { fetchScenes, executeScene } from '../lib/scenes.js';
|
|
9
10
|
import { findCatalogEntry } from '../devices/catalog.js';
|
|
@@ -11,6 +12,7 @@ import { getCachedDevice } from '../devices/cache.js';
|
|
|
11
12
|
import { EventSubscriptionManager } from '../mcp/events-subscription.js';
|
|
12
13
|
import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
13
14
|
import { queryDeviceHistory } from '../devices/history-query.js';
|
|
15
|
+
import { aggregateDeviceHistory, ALL_AGG_FNS, MAX_SAMPLE_CAP, } from '../devices/history-agg.js';
|
|
14
16
|
import { todayUsage } from '../utils/quota.js';
|
|
15
17
|
import { describeCache } from '../devices/cache.js';
|
|
16
18
|
import { withRequestContext } from '../lib/request-context.js';
|
|
@@ -24,16 +26,41 @@ function mcpError(kind, code, message, options) {
|
|
|
24
26
|
obj.retryable = true;
|
|
25
27
|
if (options?.context)
|
|
26
28
|
obj.context = options.context;
|
|
29
|
+
if (options?.subKind !== undefined)
|
|
30
|
+
obj.subKind = options.subKind;
|
|
31
|
+
if (options?.errorClass !== undefined)
|
|
32
|
+
obj.errorClass = options.errorClass;
|
|
33
|
+
if (options?.transient !== undefined)
|
|
34
|
+
obj.transient = options.transient;
|
|
35
|
+
if (options?.retryAfterMs !== undefined)
|
|
36
|
+
obj.retryAfterMs = options.retryAfterMs;
|
|
27
37
|
return {
|
|
28
38
|
isError: true,
|
|
29
39
|
content: [{ type: 'text', text: JSON.stringify({ error: obj }, null, 2) }],
|
|
40
|
+
structuredContent: { error: obj },
|
|
30
41
|
};
|
|
31
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Convert any thrown error into a structured MCP tool-error response,
|
|
45
|
+
* preserving all ErrorPayload fields (subKind, transient, hint, etc.).
|
|
46
|
+
*/
|
|
47
|
+
function apiErrorToMcpError(err) {
|
|
48
|
+
const payload = buildErrorPayload(err);
|
|
49
|
+
return mcpError(payload.kind, payload.code, payload.message, {
|
|
50
|
+
hint: payload.hint,
|
|
51
|
+
retryable: payload.retryable,
|
|
52
|
+
context: payload.context,
|
|
53
|
+
subKind: payload.subKind,
|
|
54
|
+
errorClass: payload.errorClass,
|
|
55
|
+
transient: payload.transient,
|
|
56
|
+
retryAfterMs: payload.retryAfterMs,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
32
59
|
export function createSwitchBotMcpServer(options) {
|
|
33
60
|
const eventManager = options?.eventManager;
|
|
34
61
|
const server = new McpServer({
|
|
35
62
|
name: 'switchbot',
|
|
36
|
-
version:
|
|
63
|
+
version: VERSION,
|
|
37
64
|
}, {
|
|
38
65
|
capabilities: { tools: {}, resources: {} },
|
|
39
66
|
instructions: `SwitchBot is an IoT smart home brand by Wonderlabs, Inc. This MCP server controls physical devices \
|
|
@@ -60,7 +87,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
60
87
|
server.registerTool('list_devices', {
|
|
61
88
|
title: 'List all devices on the account',
|
|
62
89
|
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.',
|
|
63
|
-
|
|
90
|
+
_meta: { agentSafetyTier: 'read' },
|
|
91
|
+
inputSchema: z.object({}).strict(),
|
|
64
92
|
outputSchema: {
|
|
65
93
|
deviceList: z.array(z.object({
|
|
66
94
|
deviceId: z.string(),
|
|
@@ -95,9 +123,10 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
95
123
|
server.registerTool('get_device_status', {
|
|
96
124
|
title: 'Get live status for a device',
|
|
97
125
|
description: 'Query the real-time status payload for a physical device. IR remotes have no status channel and will error.',
|
|
98
|
-
|
|
126
|
+
_meta: { agentSafetyTier: 'read' },
|
|
127
|
+
inputSchema: z.object({
|
|
99
128
|
deviceId: z.string().describe('Device ID from list_devices'),
|
|
100
|
-
},
|
|
129
|
+
}).strict(),
|
|
101
130
|
outputSchema: {
|
|
102
131
|
status: z.object({
|
|
103
132
|
deviceId: z.string().optional(),
|
|
@@ -119,10 +148,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
119
148
|
description: 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' +
|
|
120
149
|
'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' +
|
|
121
150
|
'Omit deviceId to list all devices with stored history.',
|
|
122
|
-
|
|
151
|
+
_meta: { agentSafetyTier: 'read' },
|
|
152
|
+
inputSchema: z.object({
|
|
123
153
|
deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'),
|
|
124
154
|
limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'),
|
|
125
|
-
},
|
|
155
|
+
}).strict(),
|
|
126
156
|
outputSchema: {
|
|
127
157
|
deviceId: z.string().optional(),
|
|
128
158
|
latest: z.unknown().optional(),
|
|
@@ -153,14 +183,15 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
153
183
|
description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) ' +
|
|
154
184
|
'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' +
|
|
155
185
|
'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
|
|
156
|
-
|
|
186
|
+
_meta: { agentSafetyTier: 'read' },
|
|
187
|
+
inputSchema: z.object({
|
|
157
188
|
deviceId: z.string().describe('Device ID to query'),
|
|
158
189
|
since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
|
|
159
190
|
from: z.string().optional().describe('Range start (ISO-8601).'),
|
|
160
191
|
to: z.string().optional().describe('Range end (ISO-8601).'),
|
|
161
192
|
fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'),
|
|
162
193
|
limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'),
|
|
163
|
-
},
|
|
194
|
+
}).strict(),
|
|
164
195
|
outputSchema: {
|
|
165
196
|
deviceId: z.string(),
|
|
166
197
|
count: z.number().int(),
|
|
@@ -192,7 +223,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
192
223
|
server.registerTool('send_command', {
|
|
193
224
|
title: 'Send a control command to a device',
|
|
194
225
|
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.',
|
|
195
|
-
|
|
226
|
+
_meta: { agentSafetyTier: 'action' },
|
|
227
|
+
inputSchema: z.object({
|
|
196
228
|
deviceId: z.string().describe('Device ID from list_devices'),
|
|
197
229
|
command: z.string().describe('Command name, case-sensitive (e.g. turnOn, setColor, unlock)'),
|
|
198
230
|
parameter: z
|
|
@@ -213,12 +245,16 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
213
245
|
.string()
|
|
214
246
|
.optional()
|
|
215
247
|
.describe('Deduplication key — repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error.'),
|
|
216
|
-
|
|
248
|
+
dryRun: z
|
|
249
|
+
.boolean()
|
|
250
|
+
.optional()
|
|
251
|
+
.describe('When true, do not call the API — return { ok:true, dryRun:true, wouldSend:{...} } instead.'),
|
|
252
|
+
}).strict(),
|
|
217
253
|
outputSchema: {
|
|
218
254
|
ok: z.literal(true),
|
|
219
|
-
command: z.string(),
|
|
220
|
-
deviceId: z.string(),
|
|
221
|
-
result: z.unknown().describe('API response body from SwitchBot'),
|
|
255
|
+
command: z.string().optional(),
|
|
256
|
+
deviceId: z.string().optional(),
|
|
257
|
+
result: z.unknown().optional().describe('API response body from SwitchBot (absent on dryRun)'),
|
|
222
258
|
verification: z
|
|
223
259
|
.object({
|
|
224
260
|
verifiable: z.boolean(),
|
|
@@ -227,9 +263,41 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
227
263
|
})
|
|
228
264
|
.optional()
|
|
229
265
|
.describe('Present when the target is an IR device. IR is unidirectional — agents should treat the success as "signal sent" not "state changed".'),
|
|
266
|
+
dryRun: z.literal(true).optional().describe('Present when dryRun:true was requested'),
|
|
267
|
+
wouldSend: z.object({
|
|
268
|
+
deviceId: z.string(),
|
|
269
|
+
command: z.string(),
|
|
270
|
+
parameter: z.unknown(),
|
|
271
|
+
commandType: z.string(),
|
|
272
|
+
}).optional().describe('The request shape that would have been POSTed (present when dryRun:true)'),
|
|
230
273
|
},
|
|
231
|
-
}, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey }) => {
|
|
274
|
+
}, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
|
|
232
275
|
const effectiveType = commandType ?? 'command';
|
|
276
|
+
// dryRun early-return — no API call. We still preflight the deviceId
|
|
277
|
+
// against the local cache so fabricated IDs don't silently pass
|
|
278
|
+
// validation (bug #SYS-3). Dry-run is meant to catch bad inputs; a
|
|
279
|
+
// dry-run that accepts anything is worse than no dry-run at all.
|
|
280
|
+
if (dryRun) {
|
|
281
|
+
const cached = getCachedDevice(deviceId);
|
|
282
|
+
if (!cached) {
|
|
283
|
+
return mcpError('usage', 2, `Device "${deviceId}" not found in local cache.`, {
|
|
284
|
+
subKind: 'device-not-found',
|
|
285
|
+
hint: "Run 'list_devices' first to warm the cache, then retry with dryRun:true.",
|
|
286
|
+
context: { deviceId },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
const wouldSend = {
|
|
290
|
+
deviceId,
|
|
291
|
+
command,
|
|
292
|
+
parameter: parameter ?? 'default',
|
|
293
|
+
commandType: effectiveType,
|
|
294
|
+
};
|
|
295
|
+
const structured = { ok: true, dryRun: true, wouldSend };
|
|
296
|
+
return {
|
|
297
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
298
|
+
structuredContent: structured,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
233
301
|
// Resolve the device's catalog type via cache or a fresh lookup so we
|
|
234
302
|
// can evaluate destructive/validation without an extra round-trip if
|
|
235
303
|
// the cache is warm.
|
|
@@ -287,7 +355,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
287
355
|
},
|
|
288
356
|
});
|
|
289
357
|
}
|
|
290
|
-
|
|
358
|
+
return apiErrorToMcpError(err);
|
|
291
359
|
}
|
|
292
360
|
const isIr = getCachedDevice(deviceId)?.category === 'ir';
|
|
293
361
|
const structured = { ok: true, command, deviceId, result };
|
|
@@ -307,15 +375,37 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
307
375
|
server.registerTool('run_scene', {
|
|
308
376
|
title: 'Execute a manual scene',
|
|
309
377
|
description: 'Execute a manual SwitchBot scene by its sceneId (from list_scenes).',
|
|
310
|
-
|
|
378
|
+
_meta: { agentSafetyTier: 'action' },
|
|
379
|
+
inputSchema: z.object({
|
|
311
380
|
sceneId: z.string().describe('Scene ID from list_scenes'),
|
|
312
|
-
|
|
381
|
+
dryRun: z
|
|
382
|
+
.boolean()
|
|
383
|
+
.optional()
|
|
384
|
+
.describe('When true, do not call the API — return { ok:true, dryRun:true, wouldSend:{...} } instead.'),
|
|
385
|
+
}).strict(),
|
|
313
386
|
outputSchema: {
|
|
314
387
|
ok: z.literal(true),
|
|
315
|
-
sceneId: z.string(),
|
|
388
|
+
sceneId: z.string().optional(),
|
|
389
|
+
dryRun: z.literal(true).optional().describe('Present when dryRun:true was requested'),
|
|
390
|
+
wouldSend: z.object({
|
|
391
|
+
sceneId: z.string(),
|
|
392
|
+
}).optional().describe('The request shape that would have been POSTed (present when dryRun:true)'),
|
|
316
393
|
},
|
|
317
|
-
}, async ({ sceneId }) => {
|
|
318
|
-
|
|
394
|
+
}, async ({ sceneId, dryRun }) => {
|
|
395
|
+
if (dryRun) {
|
|
396
|
+
const wouldSend = { sceneId };
|
|
397
|
+
const structured = { ok: true, dryRun: true, wouldSend };
|
|
398
|
+
return {
|
|
399
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
400
|
+
structuredContent: structured,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
await executeScene(sceneId);
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
return apiErrorToMcpError(err);
|
|
408
|
+
}
|
|
319
409
|
const structured = { ok: true, sceneId };
|
|
320
410
|
return {
|
|
321
411
|
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
@@ -326,7 +416,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
326
416
|
server.registerTool('list_scenes', {
|
|
327
417
|
title: 'List all manual scenes',
|
|
328
418
|
description: 'Fetch all manual scenes configured in the SwitchBot app.',
|
|
329
|
-
|
|
419
|
+
_meta: { agentSafetyTier: 'read' },
|
|
420
|
+
inputSchema: z.object({}).strict(),
|
|
330
421
|
outputSchema: {
|
|
331
422
|
scenes: z.array(z.object({ sceneId: z.string(), sceneName: z.string() })),
|
|
332
423
|
},
|
|
@@ -341,10 +432,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
341
432
|
server.registerTool('search_catalog', {
|
|
342
433
|
title: 'Search the offline device catalog',
|
|
343
434
|
description: 'Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.',
|
|
344
|
-
|
|
345
|
-
|
|
435
|
+
_meta: { agentSafetyTier: 'read' },
|
|
436
|
+
inputSchema: z.object({
|
|
437
|
+
query: z.string().describe('Search query (matches type and aliases, case-insensitive). Must be non-empty; use list_catalog_types to enumerate instead.'),
|
|
346
438
|
limit: z.number().int().min(1).max(100).optional().default(20).describe('Max entries returned (default 20)'),
|
|
347
|
-
},
|
|
439
|
+
}).strict(),
|
|
348
440
|
outputSchema: {
|
|
349
441
|
results: z.array(z.object({
|
|
350
442
|
type: z.string(),
|
|
@@ -365,6 +457,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
365
457
|
total: z.number().int().describe('Number of entries returned'),
|
|
366
458
|
},
|
|
367
459
|
}, async ({ query, limit }) => {
|
|
460
|
+
if (query.trim() === '') {
|
|
461
|
+
return mcpError('usage', 2, 'search_catalog requires a non-empty query.', {
|
|
462
|
+
hint: "Pass a search term like 'Bot' or 'Hub', or call list_catalog_types to enumerate all types without a query.",
|
|
463
|
+
});
|
|
464
|
+
}
|
|
368
465
|
const hits = searchCatalog(query, limit);
|
|
369
466
|
const structured = { results: hits, total: hits.length };
|
|
370
467
|
return {
|
|
@@ -376,10 +473,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
376
473
|
server.registerTool('describe_device', {
|
|
377
474
|
title: 'Describe a specific device',
|
|
378
475
|
description: 'Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.',
|
|
379
|
-
|
|
476
|
+
_meta: { agentSafetyTier: 'read' },
|
|
477
|
+
inputSchema: z.object({
|
|
380
478
|
deviceId: z.string().describe('Device ID from list_devices'),
|
|
381
479
|
live: z.boolean().optional().default(false).describe('Also fetch live /status values (costs 1 extra API call)'),
|
|
382
|
-
},
|
|
480
|
+
}).strict(),
|
|
383
481
|
outputSchema: {
|
|
384
482
|
device: z.object({
|
|
385
483
|
device: z.object({ deviceId: z.string(), deviceName: z.string() }).passthrough(),
|
|
@@ -414,14 +512,48 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
414
512
|
context: { deviceId },
|
|
415
513
|
});
|
|
416
514
|
}
|
|
417
|
-
|
|
515
|
+
return apiErrorToMcpError(err);
|
|
418
516
|
}
|
|
419
517
|
});
|
|
518
|
+
// ---- aggregate_device_history --------------------------------------------
|
|
519
|
+
server.registerTool('aggregate_device_history', {
|
|
520
|
+
title: 'Aggregate device history',
|
|
521
|
+
description: 'Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL-recorded device history. Read-only; no network calls.',
|
|
522
|
+
_meta: { agentSafetyTier: 'read' },
|
|
523
|
+
inputSchema: z
|
|
524
|
+
.object({
|
|
525
|
+
deviceId: z.string().min(1),
|
|
526
|
+
since: z.string().optional(),
|
|
527
|
+
from: z.string().optional(),
|
|
528
|
+
to: z.string().optional(),
|
|
529
|
+
metrics: z.array(z.string().min(1)).min(1),
|
|
530
|
+
aggs: z.array(z.enum(ALL_AGG_FNS)).optional(),
|
|
531
|
+
bucket: z.string().optional(),
|
|
532
|
+
maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional(),
|
|
533
|
+
})
|
|
534
|
+
.strict(),
|
|
535
|
+
}, async (args) => {
|
|
536
|
+
const opts = {
|
|
537
|
+
since: args.since,
|
|
538
|
+
from: args.from,
|
|
539
|
+
to: args.to,
|
|
540
|
+
metrics: args.metrics,
|
|
541
|
+
aggs: args.aggs,
|
|
542
|
+
bucket: args.bucket,
|
|
543
|
+
maxBucketSamples: args.maxBucketSamples,
|
|
544
|
+
};
|
|
545
|
+
const res = await aggregateDeviceHistory(args.deviceId, opts);
|
|
546
|
+
return {
|
|
547
|
+
content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
|
|
548
|
+
structuredContent: res,
|
|
549
|
+
};
|
|
550
|
+
});
|
|
420
551
|
// ---- account_overview ---------------------------------------------------
|
|
421
552
|
server.registerTool('account_overview', {
|
|
422
553
|
title: 'Bootstrap account overview',
|
|
423
554
|
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.',
|
|
424
|
-
|
|
555
|
+
_meta: { agentSafetyTier: 'read' },
|
|
556
|
+
inputSchema: z.object({}).strict(),
|
|
425
557
|
outputSchema: {
|
|
426
558
|
version: z.string(),
|
|
427
559
|
schemaVersion: z.string(),
|
|
@@ -472,7 +604,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
472
604
|
const cacheInfo = describeCache();
|
|
473
605
|
const quota = todayUsage();
|
|
474
606
|
const overview = {
|
|
475
|
-
version:
|
|
607
|
+
version: VERSION,
|
|
476
608
|
schemaVersion: '1.1',
|
|
477
609
|
devices: deviceList.deviceList.map(toMcpDeviceListShape),
|
|
478
610
|
infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
|
|
@@ -533,15 +665,18 @@ export function registerMcpCommand(program) {
|
|
|
533
665
|
.command('mcp')
|
|
534
666
|
.description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
|
|
535
667
|
.addHelpText('after', `
|
|
536
|
-
The MCP server exposes
|
|
537
|
-
- list_devices
|
|
538
|
-
- get_device_status
|
|
539
|
-
- send_command
|
|
540
|
-
- list_scenes
|
|
541
|
-
- run_scene
|
|
542
|
-
- search_catalog
|
|
543
|
-
- describe_device
|
|
544
|
-
- account_overview
|
|
668
|
+
The MCP server exposes eleven tools:
|
|
669
|
+
- list_devices fetch all physical + IR devices
|
|
670
|
+
- get_device_status live status for a physical device
|
|
671
|
+
- send_command control a device (destructive commands need confirm:true)
|
|
672
|
+
- list_scenes list all manual scenes
|
|
673
|
+
- run_scene execute a manual scene
|
|
674
|
+
- search_catalog offline catalog search by type/alias
|
|
675
|
+
- describe_device metadata + commands + (optionally) live status for one device
|
|
676
|
+
- account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state
|
|
677
|
+
- get_device_history fetch raw JSONL history records for a device
|
|
678
|
+
- query_device_history filter + page history records with field/time predicates
|
|
679
|
+
- aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records
|
|
545
680
|
|
|
546
681
|
Resource (read-only):
|
|
547
682
|
- switchbot://events snapshot of recent MQTT shadow events from the ring buffer
|
|
@@ -581,7 +716,7 @@ Inspect locally:
|
|
|
581
716
|
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
582
717
|
const msg = `Invalid --port "${options.port}". Must be 1-65535.`;
|
|
583
718
|
if (isJsonMode()) {
|
|
584
|
-
|
|
719
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
585
720
|
}
|
|
586
721
|
else {
|
|
587
722
|
console.error(msg);
|
|
@@ -597,7 +732,7 @@ Inspect locally:
|
|
|
597
732
|
if (!isLocalhost && !authToken) {
|
|
598
733
|
const msg = 'Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).';
|
|
599
734
|
if (isJsonMode()) {
|
|
600
|
-
|
|
735
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
601
736
|
}
|
|
602
737
|
else {
|
|
603
738
|
console.error(msg);
|
|
@@ -650,7 +785,7 @@ Inspect locally:
|
|
|
650
785
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
651
786
|
res.end(JSON.stringify({
|
|
652
787
|
ok: true,
|
|
653
|
-
version:
|
|
788
|
+
version: VERSION,
|
|
654
789
|
pid: process.pid,
|
|
655
790
|
uptimeSec: Math.floor(process.uptime()),
|
|
656
791
|
}));
|
|
@@ -660,7 +795,7 @@ Inspect locally:
|
|
|
660
795
|
const state = eventManager.getState();
|
|
661
796
|
const ready = state !== 'failed' && state !== 'disabled';
|
|
662
797
|
const status = ready ? 200 : 503;
|
|
663
|
-
const body = { ready, version:
|
|
798
|
+
const body = { ready, version: VERSION, mqtt: state };
|
|
664
799
|
if (!ready)
|
|
665
800
|
body.reason = state === 'disabled' ? 'mqtt disabled' : 'mqtt failed';
|
|
666
801
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
package/dist/commands/plan.js
CHANGED
|
@@ -182,12 +182,22 @@ Workflow:
|
|
|
182
182
|
.command('schema')
|
|
183
183
|
.description('Print the JSON Schema for the plan format')
|
|
184
184
|
.action(() => {
|
|
185
|
-
printJson(
|
|
185
|
+
printJson({
|
|
186
|
+
...PLAN_JSON_SCHEMA,
|
|
187
|
+
agentNotes: {
|
|
188
|
+
deviceNameStrategy: "Plan step `deviceName` fields are resolved with the `require-unique` strategy (same default as `devices command`). Plans that expect a specific device should pin `deviceId` instead.",
|
|
189
|
+
},
|
|
190
|
+
});
|
|
186
191
|
});
|
|
187
192
|
plan
|
|
188
193
|
.command('validate')
|
|
189
|
-
.description('Validate a plan file (or stdin) against the schema')
|
|
194
|
+
.description('Validate a plan file (or stdin) against the schema (structural only; does not verify device or scene existence)')
|
|
190
195
|
.argument('[file]', 'Path to plan.json, or "-" / omit to read stdin')
|
|
196
|
+
.addHelpText('after', `
|
|
197
|
+
To check semantic validity (e.g., that deviceIds and sceneIds actually exist),
|
|
198
|
+
use 'plan run --dry-run' which exercises name resolution and device lookup
|
|
199
|
+
against the live API without executing any mutations.
|
|
200
|
+
`)
|
|
191
201
|
.action(async (file) => {
|
|
192
202
|
let raw;
|
|
193
203
|
try {
|