@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.
@@ -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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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 OR the file is missing
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
- printJson(report);
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
- console.log(`Audit log: ${report.file}`);
205
- console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`);
206
- console.log(`Malformed: ${report.malformedLines}`);
207
- console.log(`Unversioned: ${report.unversionedEntries}`);
208
- const versions = Object.entries(report.versionCounts)
209
- .map(([v, n]) => `${v}:${n}`)
210
- .join(', ');
211
- console.log(`Version counts: ${versions || '—'}`);
212
- if (report.earliest)
213
- console.log(`Earliest: ${report.earliest}`);
214
- if (report.latest)
215
- console.log(`Latest: ${report.latest}`);
216
- if (report.problems.length > 0) {
217
- console.log('\nProblems:');
218
- for (const p of report.problems) {
219
- console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`);
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
- const ok = report.malformedLines === 0 && report.problems.length === 0;
224
- process.exit(ok ? 0 : 1);
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
  }
@@ -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: '2.0.0',
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
- inputSchema: {},
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
- inputSchema: {
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
- inputSchema: {
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
- inputSchema: {
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
- inputSchema: {
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
- throw err;
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
- inputSchema: {
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
- await executeScene(sceneId);
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
- inputSchema: {},
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
- inputSchema: {
345
- query: z.string().describe('Search query (matches type and aliases, case-insensitive). Use empty string to list all.'),
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
- inputSchema: {
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
- throw err;
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
- inputSchema: {},
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: '2.0.0',
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 eight tools:
537
- - list_devices fetch all physical + IR devices
538
- - get_device_status live status for a physical device
539
- - send_command control a device (destructive commands need confirm:true)
540
- - list_scenes list all manual scenes
541
- - run_scene execute a manual scene
542
- - search_catalog offline catalog search by type/alias
543
- - describe_device metadata + commands + (optionally) live status for one device
544
- - account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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: '2.0.0',
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: '2.0.0', mqtt: state };
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' });
@@ -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(PLAN_JSON_SCHEMA);
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 {