@switchbot/openapi-cli 2.7.2 → 3.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 +383 -101
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/config.js +30 -0
- package/dist/commands/devices.js +0 -1
- package/dist/commands/doctor.js +184 -7
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +796 -3
- package/dist/commands/plan.js +110 -14
- package/dist/commands/policy.js +469 -0
- package/dist/commands/rules.js +657 -0
- package/dist/commands/schema.js +0 -2
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/config.js +14 -0
- package/dist/credentials/backends/file.js +101 -0
- package/dist/credentials/backends/linux.js +129 -0
- package/dist/credentials/backends/macos.js +129 -0
- package/dist/credentials/backends/windows.js +215 -0
- package/dist/credentials/keychain.js +88 -0
- package/dist/credentials/prime.js +52 -0
- package/dist/devices/catalog.js +4 -10
- package/dist/index.js +23 -1
- package/dist/install/default-steps.js +257 -0
- package/dist/install/preflight.js +212 -0
- package/dist/install/steps.js +67 -0
- package/dist/lib/command-keywords.js +17 -0
- package/dist/lib/devices.js +0 -1
- package/dist/policy/add-rule.js +124 -0
- package/dist/policy/diff.js +91 -0
- package/dist/policy/examples/policy.example.yaml +99 -0
- package/dist/policy/format.js +57 -0
- package/dist/policy/load.js +61 -0
- package/dist/policy/migrate.js +67 -0
- package/dist/policy/schema/v0.2.json +302 -0
- package/dist/policy/schema.js +18 -0
- package/dist/policy/validate.js +262 -0
- package/dist/rules/action.js +205 -0
- package/dist/rules/audit-query.js +89 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +567 -0
- package/dist/rules/matcher.js +230 -0
- package/dist/rules/pid-file.js +95 -0
- package/dist/rules/quiet-hours.js +45 -0
- package/dist/rules/suggest.js +95 -0
- package/dist/rules/throttle.js +78 -0
- package/dist/rules/types.js +34 -0
- package/dist/rules/webhook-listener.js +223 -0
- package/dist/rules/webhook-token.js +90 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/package.json +12 -4
package/dist/commands/mcp.js
CHANGED
|
@@ -18,6 +18,24 @@ import { todayUsage } from '../utils/quota.js';
|
|
|
18
18
|
import { describeCache } from '../devices/cache.js';
|
|
19
19
|
import { withRequestContext } from '../lib/request-context.js';
|
|
20
20
|
import { profileFilePath, tryLoadConfig } from '../config.js';
|
|
21
|
+
import { loadPolicyFile, resolvePolicyPath, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
|
|
22
|
+
import { validateLoadedPolicy } from '../policy/validate.js';
|
|
23
|
+
import { CURRENT_POLICY_SCHEMA_VERSION, SUPPORTED_POLICY_SCHEMA_VERSIONS, } from '../policy/schema.js';
|
|
24
|
+
import { planMigration } from '../policy/migrate.js';
|
|
25
|
+
import { suggestPlan } from './plan.js';
|
|
26
|
+
import { suggestRule } from '../rules/suggest.js';
|
|
27
|
+
import { addRuleToPolicyFile, AddRuleError } from '../policy/add-rule.js';
|
|
28
|
+
import { writeFileSync } from 'node:fs';
|
|
29
|
+
import { readAudit } from '../utils/audit.js';
|
|
30
|
+
import { parseDurationToMs } from '../utils/flags.js';
|
|
31
|
+
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
32
|
+
import { validatePlan } from './plan.js';
|
|
33
|
+
import { parse as yamlParse } from 'yaml';
|
|
34
|
+
import { diffPolicyValues } from '../policy/diff.js';
|
|
35
|
+
const LATEST_SUPPORTED_VERSION = SUPPORTED_POLICY_SCHEMA_VERSIONS[SUPPORTED_POLICY_SCHEMA_VERSIONS.length - 1];
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { dirname as pathDirname, join as pathJoin } from 'node:path';
|
|
38
|
+
import os from 'node:os';
|
|
21
39
|
import fs from 'node:fs';
|
|
22
40
|
function mcpError(kind, code, message, options) {
|
|
23
41
|
const obj = { code, kind, message };
|
|
@@ -57,6 +75,66 @@ function apiErrorToMcpError(err) {
|
|
|
57
75
|
retryAfterMs: payload.retryAfterMs,
|
|
58
76
|
});
|
|
59
77
|
}
|
|
78
|
+
const DEFAULT_AUDIT_LOG_FILE = pathJoin(os.homedir(), '.switchbot', 'audit.log');
|
|
79
|
+
function resolveAuditRange(opts) {
|
|
80
|
+
if (opts.since && (opts.from || opts.to)) {
|
|
81
|
+
throw new Error('--since is mutually exclusive with --from/--to.');
|
|
82
|
+
}
|
|
83
|
+
if (opts.since) {
|
|
84
|
+
const dur = parseDurationToMs(opts.since);
|
|
85
|
+
if (dur === null) {
|
|
86
|
+
throw new Error(`Invalid --since value "${opts.since}". Expected e.g. "30s", "15m", "1h", "7d".`);
|
|
87
|
+
}
|
|
88
|
+
return { fromMs: Date.now() - dur, toMs: Number.POSITIVE_INFINITY };
|
|
89
|
+
}
|
|
90
|
+
let fromMs = Number.NEGATIVE_INFINITY;
|
|
91
|
+
let toMs = Number.POSITIVE_INFINITY;
|
|
92
|
+
if (opts.from) {
|
|
93
|
+
const parsed = Date.parse(opts.from);
|
|
94
|
+
if (!Number.isFinite(parsed)) {
|
|
95
|
+
throw new Error(`Invalid --from value "${opts.from}". Expected ISO-8601 timestamp.`);
|
|
96
|
+
}
|
|
97
|
+
fromMs = parsed;
|
|
98
|
+
}
|
|
99
|
+
if (opts.to) {
|
|
100
|
+
const parsed = Date.parse(opts.to);
|
|
101
|
+
if (!Number.isFinite(parsed)) {
|
|
102
|
+
throw new Error(`Invalid --to value "${opts.to}". Expected ISO-8601 timestamp.`);
|
|
103
|
+
}
|
|
104
|
+
toMs = parsed;
|
|
105
|
+
}
|
|
106
|
+
if (fromMs > toMs) {
|
|
107
|
+
throw new Error('--from must be <= --to.');
|
|
108
|
+
}
|
|
109
|
+
return { fromMs, toMs };
|
|
110
|
+
}
|
|
111
|
+
function filterAuditEntries(entries, opts) {
|
|
112
|
+
const { fromMs, toMs } = resolveAuditRange(opts);
|
|
113
|
+
return entries.filter((entry) => {
|
|
114
|
+
const tMs = Date.parse(entry.t);
|
|
115
|
+
if (!Number.isFinite(tMs))
|
|
116
|
+
return false;
|
|
117
|
+
if (tMs < fromMs || tMs > toMs)
|
|
118
|
+
return false;
|
|
119
|
+
if (opts.kinds && opts.kinds.length > 0 && !opts.kinds.includes(entry.kind))
|
|
120
|
+
return false;
|
|
121
|
+
if (opts.deviceId && entry.deviceId !== opts.deviceId)
|
|
122
|
+
return false;
|
|
123
|
+
if (opts.ruleName && entry.rule?.name !== opts.ruleName)
|
|
124
|
+
return false;
|
|
125
|
+
if (opts.results && opts.results.length > 0) {
|
|
126
|
+
if (!entry.result || !opts.results.includes(entry.result))
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function topNFromMap(counts, n) {
|
|
133
|
+
return [...counts.entries()]
|
|
134
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
135
|
+
.slice(0, n)
|
|
136
|
+
.map(([key, count]) => ({ key, count }));
|
|
137
|
+
}
|
|
60
138
|
export function createSwitchBotMcpServer(options) {
|
|
61
139
|
const eventManager = options?.eventManager;
|
|
62
140
|
const server = new McpServer({
|
|
@@ -518,7 +596,6 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
518
596
|
idempotent: z.boolean().optional(),
|
|
519
597
|
safetyTier: z.enum(['read', 'mutation', 'ir-fire-forget', 'destructive', 'maintenance']).optional(),
|
|
520
598
|
safetyReason: z.string().optional(),
|
|
521
|
-
destructive: z.boolean().optional(),
|
|
522
599
|
}).passthrough()),
|
|
523
600
|
aliases: z.array(z.string()).optional(),
|
|
524
601
|
statusFields: z.array(z.string()).optional(),
|
|
@@ -542,7 +619,6 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
542
619
|
return {
|
|
543
620
|
...c,
|
|
544
621
|
safetyTier: tier,
|
|
545
|
-
destructive: tier === 'destructive',
|
|
546
622
|
...(reason ? { safetyReason: reason } : {}),
|
|
547
623
|
};
|
|
548
624
|
}),
|
|
@@ -781,6 +857,347 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
781
857
|
structuredContent: overview,
|
|
782
858
|
};
|
|
783
859
|
});
|
|
860
|
+
// ---- policy_validate -----------------------------------------------------
|
|
861
|
+
server.registerTool('policy_validate', {
|
|
862
|
+
title: 'Validate a policy.yaml file',
|
|
863
|
+
description: 'Check a policy file against the embedded JSON Schema (supports v0.1 and v0.2). ' +
|
|
864
|
+
'Returns the validation result with per-error line/col and a hint. ' +
|
|
865
|
+
'When no path is given, reads the resolved default (${SWITCHBOT_POLICY_PATH} or ~/.config/openclaw/switchbot/policy.yaml). ' +
|
|
866
|
+
'Use before relying on aliases/quiet_hours/confirmations so the agent never acts on a broken policy.',
|
|
867
|
+
_meta: { agentSafetyTier: 'read' },
|
|
868
|
+
inputSchema: z.object({
|
|
869
|
+
path: z.string().optional().describe('Optional policy file path; defaults to the resolved default path'),
|
|
870
|
+
}).strict(),
|
|
871
|
+
outputSchema: {
|
|
872
|
+
policyPath: z.string(),
|
|
873
|
+
schemaVersion: z.string(),
|
|
874
|
+
present: z.boolean().describe('false when the file does not exist'),
|
|
875
|
+
valid: z.boolean().nullable().describe('null when present=false'),
|
|
876
|
+
errors: z.array(z.object({
|
|
877
|
+
path: z.string(),
|
|
878
|
+
line: z.number().optional(),
|
|
879
|
+
col: z.number().optional(),
|
|
880
|
+
keyword: z.string(),
|
|
881
|
+
message: z.string(),
|
|
882
|
+
hint: z.string().optional(),
|
|
883
|
+
schemaPath: z.string(),
|
|
884
|
+
})).describe('Empty when valid or when the file is missing'),
|
|
885
|
+
},
|
|
886
|
+
}, async ({ path: pathArg }) => {
|
|
887
|
+
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
888
|
+
try {
|
|
889
|
+
const loaded = loadPolicyFile(policyPath);
|
|
890
|
+
const result = validateLoadedPolicy(loaded);
|
|
891
|
+
const structured = {
|
|
892
|
+
policyPath: result.policyPath,
|
|
893
|
+
schemaVersion: result.schemaVersion,
|
|
894
|
+
present: true,
|
|
895
|
+
valid: result.valid,
|
|
896
|
+
errors: result.errors,
|
|
897
|
+
};
|
|
898
|
+
return {
|
|
899
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
900
|
+
structuredContent: structured,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
catch (err) {
|
|
904
|
+
if (err instanceof PolicyFileNotFoundError) {
|
|
905
|
+
const structured = {
|
|
906
|
+
policyPath,
|
|
907
|
+
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
908
|
+
present: false,
|
|
909
|
+
valid: null,
|
|
910
|
+
errors: [],
|
|
911
|
+
};
|
|
912
|
+
return {
|
|
913
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
914
|
+
structuredContent: structured,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
if (err instanceof PolicyYamlParseError) {
|
|
918
|
+
const structured = {
|
|
919
|
+
policyPath,
|
|
920
|
+
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
921
|
+
present: true,
|
|
922
|
+
valid: false,
|
|
923
|
+
errors: err.yamlErrors.map((e) => ({
|
|
924
|
+
path: '',
|
|
925
|
+
line: e.line,
|
|
926
|
+
col: e.col,
|
|
927
|
+
keyword: 'yaml-parse',
|
|
928
|
+
message: e.message,
|
|
929
|
+
schemaPath: '',
|
|
930
|
+
})),
|
|
931
|
+
};
|
|
932
|
+
return {
|
|
933
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
934
|
+
structuredContent: structured,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
throw err;
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
// ---- policy_new ----------------------------------------------------------
|
|
941
|
+
server.registerTool('policy_new', {
|
|
942
|
+
title: 'Scaffold a starter policy.yaml',
|
|
943
|
+
description: 'Write a starter policy file to the resolved default path (or a given path). Refuses to overwrite unless force=true. ' +
|
|
944
|
+
'This is a write action: the agent should only call it after confirming with the user.',
|
|
945
|
+
_meta: { agentSafetyTier: 'action' },
|
|
946
|
+
inputSchema: z.object({
|
|
947
|
+
path: z.string().optional().describe('Optional target path; defaults to the resolved default'),
|
|
948
|
+
force: z.boolean().optional().describe('When true, overwrite an existing file'),
|
|
949
|
+
}).strict(),
|
|
950
|
+
outputSchema: {
|
|
951
|
+
policyPath: z.string(),
|
|
952
|
+
schemaVersion: z.string(),
|
|
953
|
+
bytesWritten: z.number(),
|
|
954
|
+
overwritten: z.boolean(),
|
|
955
|
+
},
|
|
956
|
+
}, async ({ path: pathArg, force }) => {
|
|
957
|
+
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
958
|
+
const doForce = force === true;
|
|
959
|
+
if (fs.existsSync(policyPath) && !doForce) {
|
|
960
|
+
return mcpError('guard', 5, `refusing to overwrite existing policy at ${policyPath}`, {
|
|
961
|
+
hint: 'pass force=true to overwrite, or choose a different path',
|
|
962
|
+
context: { policyPath },
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
const templateUrl = new URL('../policy/examples/policy.example.yaml', import.meta.url);
|
|
966
|
+
const template = fs.readFileSync(fileURLToPath(templateUrl), 'utf-8');
|
|
967
|
+
fs.mkdirSync(pathDirname(policyPath), { recursive: true });
|
|
968
|
+
fs.writeFileSync(policyPath, template, { encoding: 'utf-8' });
|
|
969
|
+
const structured = {
|
|
970
|
+
policyPath,
|
|
971
|
+
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
972
|
+
bytesWritten: Buffer.byteLength(template, 'utf-8'),
|
|
973
|
+
overwritten: doForce,
|
|
974
|
+
};
|
|
975
|
+
return {
|
|
976
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
977
|
+
structuredContent: structured,
|
|
978
|
+
};
|
|
979
|
+
});
|
|
980
|
+
// ---- policy_migrate ------------------------------------------------------
|
|
981
|
+
server.registerTool('policy_migrate', {
|
|
982
|
+
title: 'Migrate a policy file to the latest supported schema',
|
|
983
|
+
description: 'Upgrades the policy file\'s schema version in place while preserving comments. ' +
|
|
984
|
+
'Safe by default: if the migrated document would fail schema validation, the file is NOT rewritten ' +
|
|
985
|
+
'and the tool returns status="precheck-failed" with the list of errors. ' +
|
|
986
|
+
'Pass dryRun=true to preview without touching the file. ' +
|
|
987
|
+
'Currently the only supported upgrade path is v0.1 → v0.2.',
|
|
988
|
+
_meta: { agentSafetyTier: 'action' },
|
|
989
|
+
inputSchema: z.object({
|
|
990
|
+
path: z.string().optional().describe('Optional policy file path; defaults to the resolved default path'),
|
|
991
|
+
dryRun: z.boolean().optional().describe('When true, report what would change without writing'),
|
|
992
|
+
to: z.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`),
|
|
993
|
+
}).strict(),
|
|
994
|
+
outputSchema: {
|
|
995
|
+
policyPath: z.string(),
|
|
996
|
+
fileVersion: z.string().optional(),
|
|
997
|
+
targetVersion: z.string(),
|
|
998
|
+
supportedVersions: z.array(z.string()),
|
|
999
|
+
status: z.enum([
|
|
1000
|
+
'already-current',
|
|
1001
|
+
'migrated',
|
|
1002
|
+
'dry-run',
|
|
1003
|
+
'no-version-field',
|
|
1004
|
+
'unsupported',
|
|
1005
|
+
'precheck-failed',
|
|
1006
|
+
'file-not-found',
|
|
1007
|
+
]),
|
|
1008
|
+
from: z.string().optional(),
|
|
1009
|
+
to: z.string().optional(),
|
|
1010
|
+
bytesWritten: z.number().optional(),
|
|
1011
|
+
message: z.string(),
|
|
1012
|
+
errors: z
|
|
1013
|
+
.array(z.object({ path: z.string(), keyword: z.string(), message: z.string() }))
|
|
1014
|
+
.optional(),
|
|
1015
|
+
},
|
|
1016
|
+
}, async ({ path: pathArg, dryRun, to }) => {
|
|
1017
|
+
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
1018
|
+
const target = (to ?? LATEST_SUPPORTED_VERSION);
|
|
1019
|
+
let loaded;
|
|
1020
|
+
try {
|
|
1021
|
+
loaded = loadPolicyFile(policyPath);
|
|
1022
|
+
}
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
if (err instanceof PolicyFileNotFoundError) {
|
|
1025
|
+
const structured = {
|
|
1026
|
+
policyPath,
|
|
1027
|
+
targetVersion: target,
|
|
1028
|
+
supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
|
|
1029
|
+
status: 'file-not-found',
|
|
1030
|
+
message: `policy file not found: ${policyPath}`,
|
|
1031
|
+
};
|
|
1032
|
+
return {
|
|
1033
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
1034
|
+
structuredContent: structured,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
throw err;
|
|
1038
|
+
}
|
|
1039
|
+
const data = loaded.data;
|
|
1040
|
+
const fileVersion = typeof data?.version === 'string' ? data.version : undefined;
|
|
1041
|
+
const base = {
|
|
1042
|
+
policyPath,
|
|
1043
|
+
fileVersion,
|
|
1044
|
+
targetVersion: target,
|
|
1045
|
+
supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
|
|
1046
|
+
};
|
|
1047
|
+
if (!fileVersion) {
|
|
1048
|
+
const structured = {
|
|
1049
|
+
...base,
|
|
1050
|
+
status: 'no-version-field',
|
|
1051
|
+
message: `policy has no \`version\` field — add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\``,
|
|
1052
|
+
};
|
|
1053
|
+
return {
|
|
1054
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
1055
|
+
structuredContent: structured,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
|
|
1059
|
+
const structured = {
|
|
1060
|
+
...base,
|
|
1061
|
+
status: 'unsupported',
|
|
1062
|
+
message: `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(', ')})`,
|
|
1063
|
+
};
|
|
1064
|
+
return {
|
|
1065
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
1066
|
+
structuredContent: structured,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
if (fileVersion === target) {
|
|
1070
|
+
const structured = {
|
|
1071
|
+
...base,
|
|
1072
|
+
status: 'already-current',
|
|
1073
|
+
message: `already on schema v${target}; no migration needed`,
|
|
1074
|
+
bytesWritten: 0,
|
|
1075
|
+
};
|
|
1076
|
+
return {
|
|
1077
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
1078
|
+
structuredContent: structured,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
const plan = planMigration(loaded, fileVersion, target);
|
|
1082
|
+
if (!plan.precheck.valid) {
|
|
1083
|
+
const structured = {
|
|
1084
|
+
...base,
|
|
1085
|
+
status: 'precheck-failed',
|
|
1086
|
+
message: `migrated policy fails schema v${target} precheck; file not written`,
|
|
1087
|
+
errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message })),
|
|
1088
|
+
};
|
|
1089
|
+
return {
|
|
1090
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
1091
|
+
structuredContent: structured,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
const bytes = Buffer.byteLength(plan.nextSource, 'utf-8');
|
|
1095
|
+
if (dryRun) {
|
|
1096
|
+
const structured = {
|
|
1097
|
+
...base,
|
|
1098
|
+
status: 'dry-run',
|
|
1099
|
+
from: plan.fromVersion,
|
|
1100
|
+
to: plan.toVersion,
|
|
1101
|
+
bytesWritten: 0,
|
|
1102
|
+
message: `dry-run: would upgrade v${plan.fromVersion} → v${plan.toVersion} (${bytes} bytes)`,
|
|
1103
|
+
};
|
|
1104
|
+
return {
|
|
1105
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
1106
|
+
structuredContent: structured,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
writeFileSync(policyPath, plan.nextSource, { encoding: 'utf-8' });
|
|
1110
|
+
const structured = {
|
|
1111
|
+
...base,
|
|
1112
|
+
status: 'migrated',
|
|
1113
|
+
from: plan.fromVersion,
|
|
1114
|
+
to: plan.toVersion,
|
|
1115
|
+
bytesWritten: bytes,
|
|
1116
|
+
message: `migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`,
|
|
1117
|
+
};
|
|
1118
|
+
return {
|
|
1119
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
1120
|
+
structuredContent: structured,
|
|
1121
|
+
};
|
|
1122
|
+
});
|
|
1123
|
+
// ---- policy_diff ---------------------------------------------------------
|
|
1124
|
+
server.registerTool('policy_diff', {
|
|
1125
|
+
title: 'Compare two policy files',
|
|
1126
|
+
description: 'Compare two policy YAML files and return the same contract as `switchbot --json policy diff`: ' +
|
|
1127
|
+
'{ leftPath, rightPath, equal, changeCount, truncated, stats, changes, diff }.',
|
|
1128
|
+
_meta: { agentSafetyTier: 'read' },
|
|
1129
|
+
inputSchema: z.object({
|
|
1130
|
+
left_path: z.string().min(1).describe('Path to the baseline policy file.'),
|
|
1131
|
+
right_path: z.string().min(1).describe('Path to the candidate policy file.'),
|
|
1132
|
+
}).strict(),
|
|
1133
|
+
outputSchema: {
|
|
1134
|
+
leftPath: z.string(),
|
|
1135
|
+
rightPath: z.string(),
|
|
1136
|
+
equal: z.boolean(),
|
|
1137
|
+
changeCount: z.number().int(),
|
|
1138
|
+
truncated: z.boolean(),
|
|
1139
|
+
stats: z.object({
|
|
1140
|
+
added: z.number().int(),
|
|
1141
|
+
removed: z.number().int(),
|
|
1142
|
+
changed: z.number().int(),
|
|
1143
|
+
}),
|
|
1144
|
+
changes: z.array(z.object({
|
|
1145
|
+
path: z.string(),
|
|
1146
|
+
kind: z.enum(['added', 'removed', 'changed']),
|
|
1147
|
+
before: z.unknown().optional(),
|
|
1148
|
+
after: z.unknown().optional(),
|
|
1149
|
+
})),
|
|
1150
|
+
diff: z.string(),
|
|
1151
|
+
},
|
|
1152
|
+
}, ({ left_path, right_path }) => {
|
|
1153
|
+
let leftSource = '';
|
|
1154
|
+
let rightSource = '';
|
|
1155
|
+
try {
|
|
1156
|
+
leftSource = fs.readFileSync(left_path, 'utf-8');
|
|
1157
|
+
}
|
|
1158
|
+
catch (err) {
|
|
1159
|
+
if (err?.code === 'ENOENT') {
|
|
1160
|
+
return mcpError('usage', 2, `policy file not found: ${left_path}`, {
|
|
1161
|
+
context: { policyPath: left_path },
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
return mcpError('runtime', 1, `failed to read ${left_path}: ${String(err)}`);
|
|
1165
|
+
}
|
|
1166
|
+
try {
|
|
1167
|
+
rightSource = fs.readFileSync(right_path, 'utf-8');
|
|
1168
|
+
}
|
|
1169
|
+
catch (err) {
|
|
1170
|
+
if (err?.code === 'ENOENT') {
|
|
1171
|
+
return mcpError('usage', 2, `policy file not found: ${right_path}`, {
|
|
1172
|
+
context: { policyPath: right_path },
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
return mcpError('runtime', 1, `failed to read ${right_path}: ${String(err)}`);
|
|
1176
|
+
}
|
|
1177
|
+
let leftDoc;
|
|
1178
|
+
let rightDoc;
|
|
1179
|
+
try {
|
|
1180
|
+
leftDoc = yamlParse(leftSource);
|
|
1181
|
+
}
|
|
1182
|
+
catch (err) {
|
|
1183
|
+
return mcpError('usage', 2, `YAML parse error in ${left_path}: ${err.message}`);
|
|
1184
|
+
}
|
|
1185
|
+
try {
|
|
1186
|
+
rightDoc = yamlParse(rightSource);
|
|
1187
|
+
}
|
|
1188
|
+
catch (err) {
|
|
1189
|
+
return mcpError('usage', 2, `YAML parse error in ${right_path}: ${err.message}`);
|
|
1190
|
+
}
|
|
1191
|
+
const result = {
|
|
1192
|
+
leftPath: left_path,
|
|
1193
|
+
rightPath: right_path,
|
|
1194
|
+
...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource),
|
|
1195
|
+
};
|
|
1196
|
+
return {
|
|
1197
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
1198
|
+
structuredContent: result,
|
|
1199
|
+
};
|
|
1200
|
+
});
|
|
784
1201
|
// switchbot://events resource — snapshot of recent shadow events from the ring buffer.
|
|
785
1202
|
// Returns up to 100 recent events. When MQTT is disabled, returns an empty list with a state note.
|
|
786
1203
|
// URI: switchbot://events (optional query: ?filter=<expression> ?limit=<n>)
|
|
@@ -802,6 +1219,372 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
802
1219
|
};
|
|
803
1220
|
});
|
|
804
1221
|
}
|
|
1222
|
+
// ---- plan_suggest ---------------------------------------------------------
|
|
1223
|
+
server.registerTool('plan_suggest', {
|
|
1224
|
+
title: 'Draft a SwitchBot execution plan from intent',
|
|
1225
|
+
description: 'Generate a candidate Plan JSON from a natural language intent and a list of device IDs. ' +
|
|
1226
|
+
'Uses keyword heuristics (no LLM) to pick the command. The returned plan is ready to pass to ' +
|
|
1227
|
+
'`plan run` — review and edit before executing. Recognised commands: turnOn, turnOff, press, ' +
|
|
1228
|
+
'lock, unlock, open, close, pause. Falls back to turnOn with a warning when intent is unclear.',
|
|
1229
|
+
_meta: { agentSafetyTier: 'read' },
|
|
1230
|
+
inputSchema: z.object({
|
|
1231
|
+
intent: z.string().min(1).describe('Natural language description of what to do (e.g. "turn off all lights").'),
|
|
1232
|
+
device_ids: z.array(z.string().min(1)).min(1).describe('Device IDs to act on.'),
|
|
1233
|
+
}).strict(),
|
|
1234
|
+
outputSchema: {
|
|
1235
|
+
plan: z.unknown().describe('Candidate Plan JSON (version 1.0) ready to pass to plan run.'),
|
|
1236
|
+
warnings: z.array(z.string()).describe('Informational warnings (e.g. unrecognized intent defaulted to turnOn).'),
|
|
1237
|
+
},
|
|
1238
|
+
}, ({ intent, device_ids }) => {
|
|
1239
|
+
const devices = device_ids.map((id) => {
|
|
1240
|
+
const cached = getCachedDevice(id);
|
|
1241
|
+
return { id, name: cached?.name, type: cached?.type };
|
|
1242
|
+
});
|
|
1243
|
+
try {
|
|
1244
|
+
const { plan, warnings } = suggestPlan({ intent, devices });
|
|
1245
|
+
return {
|
|
1246
|
+
content: [{ type: 'text', text: JSON.stringify({ plan, warnings }, null, 2) }],
|
|
1247
|
+
structuredContent: { plan, warnings },
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
catch (err) {
|
|
1251
|
+
return apiErrorToMcpError(err);
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
// ---- plan_run -------------------------------------------------------------
|
|
1255
|
+
server.registerTool('plan_run', {
|
|
1256
|
+
title: 'Validate and execute a SwitchBot plan',
|
|
1257
|
+
description: 'Execute a Plan JSON object (version 1.0). Destructive command steps are skipped unless yes=true. ' +
|
|
1258
|
+
'Scene and wait steps run in order. Returns per-step results and a summary.',
|
|
1259
|
+
_meta: { agentSafetyTier: 'action' },
|
|
1260
|
+
inputSchema: z.object({
|
|
1261
|
+
plan: z.unknown().describe('Plan JSON object (same schema as `switchbot plan run`).'),
|
|
1262
|
+
yes: z.boolean().optional().describe('Authorize destructive command steps.'),
|
|
1263
|
+
continue_on_error: z.boolean().optional().describe('Keep executing later steps after a failed step.'),
|
|
1264
|
+
}).strict(),
|
|
1265
|
+
outputSchema: {
|
|
1266
|
+
ran: z.boolean(),
|
|
1267
|
+
plan: z.unknown(),
|
|
1268
|
+
results: z.array(z.unknown()),
|
|
1269
|
+
summary: z.object({
|
|
1270
|
+
total: z.number().int(),
|
|
1271
|
+
ok: z.number().int(),
|
|
1272
|
+
error: z.number().int(),
|
|
1273
|
+
skipped: z.number().int(),
|
|
1274
|
+
}),
|
|
1275
|
+
},
|
|
1276
|
+
}, async ({ plan, yes, continue_on_error }) => {
|
|
1277
|
+
const validated = validatePlan(plan);
|
|
1278
|
+
if (!validated.ok) {
|
|
1279
|
+
return mcpError('usage', 2, 'plan invalid', {
|
|
1280
|
+
context: { issues: validated.issues },
|
|
1281
|
+
hint: 'Fix the reported issues and retry plan_run.',
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
const out = {
|
|
1285
|
+
ran: true,
|
|
1286
|
+
plan: validated.plan,
|
|
1287
|
+
results: [],
|
|
1288
|
+
summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 },
|
|
1289
|
+
};
|
|
1290
|
+
const continueOnError = continue_on_error === true;
|
|
1291
|
+
const allowDestructive = yes === true;
|
|
1292
|
+
for (let i = 0; i < validated.plan.steps.length; i++) {
|
|
1293
|
+
const step = validated.plan.steps[i];
|
|
1294
|
+
const idx = i + 1;
|
|
1295
|
+
if (step.type === 'wait') {
|
|
1296
|
+
await new Promise((resolve) => setTimeout(resolve, step.ms));
|
|
1297
|
+
out.results.push({ step: idx, type: 'wait', ms: step.ms, status: 'ok' });
|
|
1298
|
+
out.summary.ok++;
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
if (step.type === 'scene') {
|
|
1302
|
+
try {
|
|
1303
|
+
await executeScene(step.sceneId);
|
|
1304
|
+
out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'ok' });
|
|
1305
|
+
out.summary.ok++;
|
|
1306
|
+
}
|
|
1307
|
+
catch (err) {
|
|
1308
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1309
|
+
out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'error', error: msg });
|
|
1310
|
+
out.summary.error++;
|
|
1311
|
+
if (!continueOnError)
|
|
1312
|
+
break;
|
|
1313
|
+
}
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
let resolvedDeviceId = '';
|
|
1317
|
+
try {
|
|
1318
|
+
resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
|
|
1319
|
+
const commandType = step.commandType ?? 'command';
|
|
1320
|
+
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
1321
|
+
const destructive = isDestructiveCommand(deviceType, step.command, commandType);
|
|
1322
|
+
if (destructive && !allowDestructive) {
|
|
1323
|
+
out.results.push({
|
|
1324
|
+
step: idx,
|
|
1325
|
+
type: 'command',
|
|
1326
|
+
deviceId: resolvedDeviceId,
|
|
1327
|
+
command: step.command,
|
|
1328
|
+
status: 'skipped',
|
|
1329
|
+
error: 'destructive — rerun with yes=true',
|
|
1330
|
+
});
|
|
1331
|
+
out.summary.skipped++;
|
|
1332
|
+
if (!continueOnError)
|
|
1333
|
+
break;
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
|
|
1337
|
+
out.results.push({
|
|
1338
|
+
step: idx,
|
|
1339
|
+
type: 'command',
|
|
1340
|
+
deviceId: resolvedDeviceId,
|
|
1341
|
+
command: step.command,
|
|
1342
|
+
status: 'ok',
|
|
1343
|
+
});
|
|
1344
|
+
out.summary.ok++;
|
|
1345
|
+
}
|
|
1346
|
+
catch (err) {
|
|
1347
|
+
if (err instanceof Error && err.name === 'DryRunSignal') {
|
|
1348
|
+
out.results.push({
|
|
1349
|
+
step: idx,
|
|
1350
|
+
type: 'command',
|
|
1351
|
+
deviceId: resolvedDeviceId || step.deviceId || 'unknown',
|
|
1352
|
+
command: step.command,
|
|
1353
|
+
status: 'ok',
|
|
1354
|
+
});
|
|
1355
|
+
out.summary.ok++;
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1359
|
+
out.results.push({
|
|
1360
|
+
step: idx,
|
|
1361
|
+
type: 'command',
|
|
1362
|
+
deviceId: resolvedDeviceId || step.deviceId || 'unknown',
|
|
1363
|
+
command: step.command,
|
|
1364
|
+
status: 'error',
|
|
1365
|
+
error: msg,
|
|
1366
|
+
});
|
|
1367
|
+
out.summary.error++;
|
|
1368
|
+
if (!continueOnError)
|
|
1369
|
+
break;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
return {
|
|
1373
|
+
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
|
|
1374
|
+
structuredContent: out,
|
|
1375
|
+
};
|
|
1376
|
+
});
|
|
1377
|
+
// ---- audit_query ----------------------------------------------------------
|
|
1378
|
+
server.registerTool('audit_query', {
|
|
1379
|
+
title: 'Query command/rule audit log entries',
|
|
1380
|
+
description: 'Filter entries from the local audit log (default ~/.switchbot/audit.log) by time range, kind, device, rule, and result. ' +
|
|
1381
|
+
'Useful for review flows and rule-fire inspection without leaving MCP.',
|
|
1382
|
+
_meta: { agentSafetyTier: 'read' },
|
|
1383
|
+
inputSchema: z.object({
|
|
1384
|
+
file: z.string().optional().describe('Optional audit log path; defaults to ~/.switchbot/audit.log.'),
|
|
1385
|
+
since: z.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
|
|
1386
|
+
from: z.string().optional().describe('Range start (ISO-8601).'),
|
|
1387
|
+
to: z.string().optional().describe('Range end (ISO-8601).'),
|
|
1388
|
+
kinds: z.array(z.enum(['command', 'rule-fire', 'rule-fire-dry', 'rule-throttled', 'rule-webhook-rejected'])).optional().describe('Filter by entry kind.'),
|
|
1389
|
+
device_id: z.string().optional().describe('Filter by deviceId.'),
|
|
1390
|
+
rule_name: z.string().optional().describe('Filter by rule.name (rule-engine entries).'),
|
|
1391
|
+
results: z.array(z.enum(['ok', 'error'])).optional().describe('Filter by execution result.'),
|
|
1392
|
+
limit: z.number().int().min(1).max(5000).optional().describe('Max entries returned from the tail of the filtered set (default 200).'),
|
|
1393
|
+
}).strict(),
|
|
1394
|
+
outputSchema: {
|
|
1395
|
+
file: z.string(),
|
|
1396
|
+
totalMatched: z.number().int(),
|
|
1397
|
+
returned: z.number().int(),
|
|
1398
|
+
entries: z.array(z.unknown()),
|
|
1399
|
+
},
|
|
1400
|
+
}, ({ file, since, from, to, kinds, device_id, rule_name, results, limit }) => {
|
|
1401
|
+
const filePath = file ?? DEFAULT_AUDIT_LOG_FILE;
|
|
1402
|
+
const entries = readAudit(filePath);
|
|
1403
|
+
try {
|
|
1404
|
+
const filtered = filterAuditEntries(entries, {
|
|
1405
|
+
since,
|
|
1406
|
+
from,
|
|
1407
|
+
to,
|
|
1408
|
+
kinds,
|
|
1409
|
+
deviceId: device_id,
|
|
1410
|
+
ruleName: rule_name,
|
|
1411
|
+
results,
|
|
1412
|
+
});
|
|
1413
|
+
const bounded = filtered.slice(-Math.max(1, limit ?? 200));
|
|
1414
|
+
const out = {
|
|
1415
|
+
file: filePath,
|
|
1416
|
+
totalMatched: filtered.length,
|
|
1417
|
+
returned: bounded.length,
|
|
1418
|
+
entries: bounded,
|
|
1419
|
+
};
|
|
1420
|
+
return {
|
|
1421
|
+
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
|
|
1422
|
+
structuredContent: out,
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
catch (err) {
|
|
1426
|
+
return mcpError('usage', 2, err instanceof Error ? err.message : 'invalid audit query options');
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
// ---- audit_stats ----------------------------------------------------------
|
|
1430
|
+
server.registerTool('audit_stats', {
|
|
1431
|
+
title: 'Aggregate audit log counts for review dashboards',
|
|
1432
|
+
description: 'Compute summary counters over the local audit log: by kind, by result, top devices, and top rules. ' +
|
|
1433
|
+
'Supports the same filters as audit_query.',
|
|
1434
|
+
_meta: { agentSafetyTier: 'read' },
|
|
1435
|
+
inputSchema: z.object({
|
|
1436
|
+
file: z.string().optional().describe('Optional audit log path; defaults to ~/.switchbot/audit.log.'),
|
|
1437
|
+
since: z.string().optional().describe('Relative window ending now (e.g. "6h"). Mutually exclusive with from/to.'),
|
|
1438
|
+
from: z.string().optional().describe('Range start (ISO-8601).'),
|
|
1439
|
+
to: z.string().optional().describe('Range end (ISO-8601).'),
|
|
1440
|
+
kinds: z.array(z.enum(['command', 'rule-fire', 'rule-fire-dry', 'rule-throttled', 'rule-webhook-rejected'])).optional().describe('Filter by entry kind.'),
|
|
1441
|
+
device_id: z.string().optional().describe('Filter by deviceId.'),
|
|
1442
|
+
rule_name: z.string().optional().describe('Filter by rule.name (rule-engine entries).'),
|
|
1443
|
+
results: z.array(z.enum(['ok', 'error'])).optional().describe('Filter by execution result.'),
|
|
1444
|
+
top_n: z.number().int().min(1).max(100).optional().describe('Number of top device/rule rows to return (default 10).'),
|
|
1445
|
+
}).strict(),
|
|
1446
|
+
outputSchema: {
|
|
1447
|
+
file: z.string(),
|
|
1448
|
+
totalMatched: z.number().int(),
|
|
1449
|
+
byKind: z.record(z.string(), z.number().int()),
|
|
1450
|
+
byResult: z.record(z.string(), z.number().int()),
|
|
1451
|
+
topDevices: z.array(z.object({ deviceId: z.string(), count: z.number().int() })),
|
|
1452
|
+
topRules: z.array(z.object({ ruleName: z.string(), count: z.number().int() })),
|
|
1453
|
+
},
|
|
1454
|
+
}, ({ file, since, from, to, kinds, device_id, rule_name, results, top_n }) => {
|
|
1455
|
+
const filePath = file ?? DEFAULT_AUDIT_LOG_FILE;
|
|
1456
|
+
const entries = readAudit(filePath);
|
|
1457
|
+
try {
|
|
1458
|
+
const filtered = filterAuditEntries(entries, {
|
|
1459
|
+
since,
|
|
1460
|
+
from,
|
|
1461
|
+
to,
|
|
1462
|
+
kinds,
|
|
1463
|
+
deviceId: device_id,
|
|
1464
|
+
ruleName: rule_name,
|
|
1465
|
+
results,
|
|
1466
|
+
});
|
|
1467
|
+
const byKind = new Map();
|
|
1468
|
+
const byResult = new Map();
|
|
1469
|
+
const byDevice = new Map();
|
|
1470
|
+
const byRule = new Map();
|
|
1471
|
+
for (const entry of filtered) {
|
|
1472
|
+
byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + 1);
|
|
1473
|
+
if (entry.result)
|
|
1474
|
+
byResult.set(entry.result, (byResult.get(entry.result) ?? 0) + 1);
|
|
1475
|
+
if (entry.deviceId)
|
|
1476
|
+
byDevice.set(entry.deviceId, (byDevice.get(entry.deviceId) ?? 0) + 1);
|
|
1477
|
+
if (entry.rule?.name)
|
|
1478
|
+
byRule.set(entry.rule.name, (byRule.get(entry.rule.name) ?? 0) + 1);
|
|
1479
|
+
}
|
|
1480
|
+
const topN = top_n ?? 10;
|
|
1481
|
+
const out = {
|
|
1482
|
+
file: filePath,
|
|
1483
|
+
totalMatched: filtered.length,
|
|
1484
|
+
byKind: Object.fromEntries([...byKind.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
|
|
1485
|
+
byResult: Object.fromEntries([...byResult.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
|
|
1486
|
+
topDevices: topNFromMap(byDevice, topN).map((item) => ({ deviceId: item.key, count: item.count })),
|
|
1487
|
+
topRules: topNFromMap(byRule, topN).map((item) => ({ ruleName: item.key, count: item.count })),
|
|
1488
|
+
};
|
|
1489
|
+
return {
|
|
1490
|
+
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
|
|
1491
|
+
structuredContent: out,
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
catch (err) {
|
|
1495
|
+
return mcpError('usage', 2, err instanceof Error ? err.message : 'invalid audit stats options');
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
// ---- rules_suggest --------------------------------------------------------
|
|
1499
|
+
server.registerTool('rules_suggest', {
|
|
1500
|
+
title: 'Draft a SwitchBot automation rule from intent',
|
|
1501
|
+
description: 'Generate a candidate automation rule YAML from a natural language intent. ' +
|
|
1502
|
+
'Uses keyword heuristics (no LLM) to infer trigger, schedule, and command. ' +
|
|
1503
|
+
'Always emits dry_run: true — the rule must be reviewed before arming. ' +
|
|
1504
|
+
'Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.',
|
|
1505
|
+
_meta: { agentSafetyTier: 'read' },
|
|
1506
|
+
inputSchema: z.object({
|
|
1507
|
+
intent: z.string().min(1).describe('Natural language description (e.g. "turn off lights at 10pm").'),
|
|
1508
|
+
trigger: z.enum(['mqtt', 'cron', 'webhook']).optional().describe('Trigger type (inferred from intent if omitted).'),
|
|
1509
|
+
device_ids: z.array(z.string().min(1)).optional().describe('Device IDs; first is sensor for mqtt triggers, rest are action targets.'),
|
|
1510
|
+
event: z.string().optional().describe('MQTT event name override (e.g. motion.detected).'),
|
|
1511
|
+
schedule: z.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
|
|
1512
|
+
days: z.array(z.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
|
|
1513
|
+
webhook_path: z.string().optional().describe('Webhook path override (default /action).'),
|
|
1514
|
+
}).strict(),
|
|
1515
|
+
outputSchema: {
|
|
1516
|
+
rule: z.unknown().describe('Rule object matching the v0.2 policy schema.'),
|
|
1517
|
+
rule_yaml: z.string().describe('YAML string ready to pipe to policy_add_rule.'),
|
|
1518
|
+
warnings: z.array(z.string()).describe('Informational warnings (e.g. unrecognized intent defaulted).'),
|
|
1519
|
+
},
|
|
1520
|
+
}, ({ intent, trigger, device_ids, event, schedule, days, webhook_path }) => {
|
|
1521
|
+
const devices = (device_ids ?? []).map((id) => {
|
|
1522
|
+
const cached = getCachedDevice(id);
|
|
1523
|
+
return { id, name: cached?.name, type: cached?.type };
|
|
1524
|
+
});
|
|
1525
|
+
try {
|
|
1526
|
+
const { rule, ruleYaml, warnings } = suggestRule({
|
|
1527
|
+
intent,
|
|
1528
|
+
trigger,
|
|
1529
|
+
devices,
|
|
1530
|
+
event,
|
|
1531
|
+
schedule,
|
|
1532
|
+
days,
|
|
1533
|
+
webhookPath: webhook_path,
|
|
1534
|
+
});
|
|
1535
|
+
return {
|
|
1536
|
+
content: [{ type: 'text', text: ruleYaml }],
|
|
1537
|
+
structuredContent: { rule, rule_yaml: ruleYaml, warnings },
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
catch (err) {
|
|
1541
|
+
return apiErrorToMcpError(err);
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
// ---- policy_add_rule ------------------------------------------------------
|
|
1545
|
+
server.registerTool('policy_add_rule', {
|
|
1546
|
+
title: 'Append a rule to automation.rules[] in policy.yaml',
|
|
1547
|
+
description: 'Inject a rule YAML snippet (as produced by rules_suggest) into the automation.rules[] ' +
|
|
1548
|
+
'array in policy.yaml. Preserves existing comments and formatting. ' +
|
|
1549
|
+
'Always run with dry_run: true first so the agent can show the diff for user approval. ' +
|
|
1550
|
+
'Never set enable_automation: true without explicitly informing the user.',
|
|
1551
|
+
_meta: { agentSafetyTier: 'action' },
|
|
1552
|
+
inputSchema: z.object({
|
|
1553
|
+
rule_yaml: z.string().min(1).describe('YAML string of a single rule object (e.g. from rules_suggest).'),
|
|
1554
|
+
policy_path: z.string().optional().describe('Path to policy.yaml (defaults to $SWITCHBOT_POLICY_PATH or ~/.switchbot/policy.yaml).'),
|
|
1555
|
+
enable_automation: z.boolean().default(false).describe('If true, sets automation.enabled: true after inserting the rule.'),
|
|
1556
|
+
dry_run: z.boolean().default(false).describe('If true, compute and return the diff without writing to disk.'),
|
|
1557
|
+
force: z.boolean().default(false).describe('If true, overwrite an existing rule with the same name.'),
|
|
1558
|
+
}).strict(),
|
|
1559
|
+
outputSchema: {
|
|
1560
|
+
policyPath: z.string().describe('Resolved path to the policy file.'),
|
|
1561
|
+
ruleName: z.string().describe('Name of the rule that was (or would be) inserted.'),
|
|
1562
|
+
written: z.boolean().describe('True when the file was actually written.'),
|
|
1563
|
+
diff: z.string().describe('Unified-style diff showing lines added/removed.'),
|
|
1564
|
+
},
|
|
1565
|
+
}, ({ rule_yaml, policy_path, enable_automation, dry_run, force }) => {
|
|
1566
|
+
const policyPath = resolvePolicyPath({ flag: policy_path });
|
|
1567
|
+
try {
|
|
1568
|
+
const result = addRuleToPolicyFile({
|
|
1569
|
+
ruleYaml: rule_yaml,
|
|
1570
|
+
policyPath,
|
|
1571
|
+
enableAutomation: enable_automation,
|
|
1572
|
+
dryRun: dry_run,
|
|
1573
|
+
force,
|
|
1574
|
+
});
|
|
1575
|
+
const out = { policyPath, ruleName: result.ruleName, written: result.written, diff: result.diff };
|
|
1576
|
+
return {
|
|
1577
|
+
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
|
|
1578
|
+
structuredContent: out,
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
catch (err) {
|
|
1582
|
+
if (err instanceof AddRuleError) {
|
|
1583
|
+
return apiErrorToMcpError(new Error(`${err.code}: ${err.message}`));
|
|
1584
|
+
}
|
|
1585
|
+
return apiErrorToMcpError(err);
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
805
1588
|
return server;
|
|
806
1589
|
}
|
|
807
1590
|
/**
|
|
@@ -821,7 +1604,7 @@ export function registerMcpCommand(program) {
|
|
|
821
1604
|
.command('mcp')
|
|
822
1605
|
.description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
|
|
823
1606
|
.addHelpText('after', `
|
|
824
|
-
The MCP server exposes
|
|
1607
|
+
The MCP server exposes twenty-one tools:
|
|
825
1608
|
- list_devices fetch all physical + IR devices
|
|
826
1609
|
- get_device_status live status for a physical device
|
|
827
1610
|
- send_command control a device (destructive commands need confirm:true)
|
|
@@ -833,6 +1616,16 @@ The MCP server exposes eleven tools:
|
|
|
833
1616
|
- get_device_history fetch raw JSONL history records for a device
|
|
834
1617
|
- query_device_history filter + page history records with field/time predicates
|
|
835
1618
|
- aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records
|
|
1619
|
+
- policy_validate check policy.yaml against the embedded schema (v0.1 / v0.2)
|
|
1620
|
+
- policy_new scaffold a starter policy.yaml (action — confirm first)
|
|
1621
|
+
- policy_migrate upgrade policy.yaml to the latest schema (action — preserves comments)
|
|
1622
|
+
- policy_diff compare two policy files with structural + line diff output
|
|
1623
|
+
- plan_suggest draft a Plan JSON from intent + device IDs (heuristic, no LLM)
|
|
1624
|
+
- plan_run validate + execute a Plan JSON document
|
|
1625
|
+
- audit_query filter audit log entries by time/device/rule/result
|
|
1626
|
+
- audit_stats aggregate audit counts by kind/result/device/rule
|
|
1627
|
+
- rules_suggest draft an automation rule YAML from intent (heuristic, no LLM)
|
|
1628
|
+
- policy_add_rule append a rule into automation.rules[] in policy.yaml
|
|
836
1629
|
|
|
837
1630
|
Resource (read-only):
|
|
838
1631
|
- switchbot://events snapshot of recent MQTT shadow events from the ring buffer
|