@thinremote/thinr-cli 1.3.2 → 1.3.4
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/CHANGELOG.md +41 -0
- package/lib/mcp/tools-alarms.js +4 -7
- package/lib/mcp/tools-buckets.js +5 -9
- package/lib/mcp/tools-devices.js +22 -11
- package/lib/mcp/tools-exec.js +19 -12
- package/lib/mcp/tools-filesystem.js +50 -28
- package/lib/mcp/tools-install.js +5 -6
- package/lib/mcp/tools-playbook.js +5 -6
- package/lib/mcp/tools-products.js +16 -24
- package/lib/mcp/tools-profile.js +2 -1
- package/lib/mcp/tools-profiles.js +1 -1
- package/lib/mcp/tools-properties.js +6 -6
- package/lib/mcp/tools-resources.js +6 -6
- package/lib/mcp/tools-scripts.js +56 -16
- package/lib/mcp/tools-tokens.js +17 -19
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.4] - 2026-09-24
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `thinr_script_delete` resolves the script by the name the agent reports, so
|
|
8
|
+
both `battery` and `battery.sh` remove the same file. It used to build the
|
|
9
|
+
path from the argument verbatim, which meant following its own description
|
|
10
|
+
("without extension") returned a bare 404. A name that matches nothing now
|
|
11
|
+
lists the scripts that are registered.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `thinr_script_write` refuses a name that would replace one of the device's
|
|
16
|
+
built-in resources (`monitoring`, `system`, `cmd`, `update`, `agent`) unless
|
|
17
|
+
`force: true` is passed. Installing `monitoring.sh` used to shadow the real
|
|
18
|
+
monitoring resource silently. Its description now also states that the
|
|
19
|
+
script runs with the agent's privileges, root in a system install.
|
|
20
|
+
- `thinr_devices` shows when each device last changed state: `connected since`
|
|
21
|
+
for a live one, `offline since` for a dead one. The timestamp was already in
|
|
22
|
+
the response; finding out whether something dropped minutes or months ago
|
|
23
|
+
meant a trip to the monitoring history.
|
|
24
|
+
- `thinr_ls` explains that `/` resolves to the agent's base directory while
|
|
25
|
+
every other absolute path is literal, and a listing of `/` now says which
|
|
26
|
+
directory it came from. Nothing is confined to that directory, so reading a
|
|
27
|
+
`/` listing as the root filesystem was an easy wrong conclusion to draw.
|
|
28
|
+
- `thinr_profiles` states that profiles are not accounts, and that `user`
|
|
29
|
+
addresses another account on the same server.
|
|
30
|
+
|
|
31
|
+
## [1.3.3] - 2026-09-24
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- MCP tools take the target device as `device_id`. A tool argument named
|
|
36
|
+
`device` never reaches this server through Claude Desktop's remote-devices
|
|
37
|
+
bridge, which claims that name for its own call routing and strips it, so
|
|
38
|
+
every tool needing a device failed with `device is required` however the
|
|
39
|
+
caller spelled the call, while tools without one worked. Reproduced on one
|
|
40
|
+
machine with one binary: a direct JSON-RPC call succeeded, the same call
|
|
41
|
+
through Claude Desktop arrived without the field, and renaming the argument
|
|
42
|
+
fixed it with nothing else changed. `device` is no longer accepted.
|
|
43
|
+
|
|
3
44
|
## [1.3.2] - 2026-09-24
|
|
4
45
|
|
|
5
46
|
### Fixed
|
package/lib/mcp/tools-alarms.js
CHANGED
|
@@ -43,9 +43,7 @@ function normalizeSeverityInput(value) {
|
|
|
43
43
|
for (const v of arr) {
|
|
44
44
|
const parsed = parseAlarmSeverity(v);
|
|
45
45
|
if (parsed === undefined) {
|
|
46
|
-
throw inputError(
|
|
47
|
-
`Unknown alarm severity "${v}". Valid: low, medium, high, critical.`,
|
|
48
|
-
);
|
|
46
|
+
throw inputError(`Unknown alarm severity "${v}". Valid: low, medium, high, critical.`);
|
|
49
47
|
}
|
|
50
48
|
out.push(parsed);
|
|
51
49
|
}
|
|
@@ -195,7 +193,8 @@ async function toolAlarmInstanceUpdate(args) {
|
|
|
195
193
|
content: [
|
|
196
194
|
{
|
|
197
195
|
type: 'text',
|
|
198
|
-
text:
|
|
196
|
+
text:
|
|
197
|
+
`Updated ${args.instance} → state=${stateLabel}` +
|
|
199
198
|
(updated?.annotation ? `, annotation="${updated.annotation}"` : ''),
|
|
200
199
|
},
|
|
201
200
|
],
|
|
@@ -228,9 +227,7 @@ async function toolAlarmRules(args) {
|
|
|
228
227
|
return `${enabled} ${id}${name}${desc}`;
|
|
229
228
|
});
|
|
230
229
|
return {
|
|
231
|
-
content: [
|
|
232
|
-
{ type: 'text', text: `${rules.length} alarm rule(s):\n${lines.join('\n')}` },
|
|
233
|
-
],
|
|
230
|
+
content: [{ type: 'text', text: `${rules.length} alarm rule(s):\n${lines.join('\n')}` }],
|
|
234
231
|
isError: false,
|
|
235
232
|
};
|
|
236
233
|
}
|
package/lib/mcp/tools-buckets.js
CHANGED
|
@@ -9,7 +9,7 @@ async function toolBucketRead(args) {
|
|
|
9
9
|
);
|
|
10
10
|
const data = await getBucketData({
|
|
11
11
|
bucket: args.bucket,
|
|
12
|
-
device: args.
|
|
12
|
+
device: args.device_id,
|
|
13
13
|
user: args.user,
|
|
14
14
|
items: args.items || 10,
|
|
15
15
|
sort: args.sort || 'desc',
|
|
@@ -22,9 +22,7 @@ async function toolBucketRead(args) {
|
|
|
22
22
|
group_by: args.group_by,
|
|
23
23
|
});
|
|
24
24
|
const text =
|
|
25
|
-
Array.isArray(data) && data.length === 0
|
|
26
|
-
? 'No data'
|
|
27
|
-
: JSON.stringify(data, null, 2);
|
|
25
|
+
Array.isArray(data) && data.length === 0 ? 'No data' : JSON.stringify(data, null, 2);
|
|
28
26
|
return {
|
|
29
27
|
content: [{ type: 'text', text }],
|
|
30
28
|
isError: false,
|
|
@@ -49,10 +47,9 @@ Custom buckets may not share the \`monitoring\` schema — don't assume a \`devi
|
|
|
49
47
|
description:
|
|
50
48
|
'Bucket ID as declared in the product profile (e.g. "monitoring", or a custom one like "sensors", "events").',
|
|
51
49
|
},
|
|
52
|
-
|
|
50
|
+
device_id: {
|
|
53
51
|
type: 'string',
|
|
54
|
-
description:
|
|
55
|
-
'Device ID. Omit for a one-row-per-device fleet view.',
|
|
52
|
+
description: 'Device ID. Omit for a one-row-per-device fleet view.',
|
|
56
53
|
},
|
|
57
54
|
items: {
|
|
58
55
|
type: 'number',
|
|
@@ -61,8 +58,7 @@ Custom buckets may not share the \`monitoring\` schema — don't assume a \`devi
|
|
|
61
58
|
},
|
|
62
59
|
minutes: {
|
|
63
60
|
type: 'number',
|
|
64
|
-
description:
|
|
65
|
-
'Get data from the last N minutes. Alternative to min_ts/max_ts.',
|
|
61
|
+
description: 'Get data from the last N minutes. Alternative to min_ts/max_ts.',
|
|
66
62
|
},
|
|
67
63
|
min_ts: { type: 'number', description: 'Minimum timestamp in milliseconds' },
|
|
68
64
|
max_ts: {
|
package/lib/mcp/tools-devices.js
CHANGED
|
@@ -10,11 +10,22 @@ async function toolDevices(args) {
|
|
|
10
10
|
const filter = query ? { name: query } : {};
|
|
11
11
|
const devices = await getDevices(filter, args.user);
|
|
12
12
|
|
|
13
|
+
// `connection.ts` is when the state last changed, so it reads as
|
|
14
|
+
// "connected since" for a live device and "offline since" for a dead
|
|
15
|
+
// one. Showing it saves a trip to the monitoring history to find out
|
|
16
|
+
// whether something dropped a minute ago or back in July.
|
|
17
|
+
const since = (d) => {
|
|
18
|
+
const ts = d.connection?.ts;
|
|
19
|
+
if (!ts) return '';
|
|
20
|
+
const when = new Date(ts).toISOString().replace('T', ' ').slice(0, 16);
|
|
21
|
+
return d.connection?.active ? ` connected since ${when}` : ` offline since ${when}`;
|
|
22
|
+
};
|
|
23
|
+
|
|
13
24
|
const renderLine = (d) => {
|
|
14
25
|
const status = d.connection?.active ? 'online ' : 'offline';
|
|
15
26
|
const label = d.name ? ` (${d.name})` : '';
|
|
16
27
|
const product = d.type && d.type !== 'Generic' ? ` [${d.type}]` : '';
|
|
17
|
-
return `${status} ${d.device}${label}${product}`;
|
|
28
|
+
return `${status} ${d.device}${label}${product}${since(d)}`;
|
|
18
29
|
};
|
|
19
30
|
|
|
20
31
|
if (!query) {
|
|
@@ -39,7 +50,7 @@ async function toolDevices(args) {
|
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
async function toolDeviceInfo(args) {
|
|
42
|
-
const { api: deviceApi } = getAPI(args.
|
|
53
|
+
const { api: deviceApi } = getAPI(args.device_id, args.user);
|
|
43
54
|
const parts = [];
|
|
44
55
|
|
|
45
56
|
// Server-side record first — surfaces the assigned product,
|
|
@@ -47,7 +58,7 @@ async function toolDeviceInfo(args) {
|
|
|
47
58
|
// can't see. Best effort: don't fail the whole tool if the
|
|
48
59
|
// record can't be read.
|
|
49
60
|
try {
|
|
50
|
-
const record = await getDevice(args.
|
|
61
|
+
const record = await getDevice(args.device_id, args.user);
|
|
51
62
|
const meta = [];
|
|
52
63
|
if (record.device) meta.push(`device: ${record.device}`);
|
|
53
64
|
if (record.name) meta.push(`name: ${record.name}`);
|
|
@@ -120,7 +131,7 @@ async function toolDeviceInfo(args) {
|
|
|
120
131
|
}
|
|
121
132
|
|
|
122
133
|
async function toolDeviceSetProduct(args) {
|
|
123
|
-
const device = args.
|
|
134
|
+
const device = args.device_id;
|
|
124
135
|
if (!device) throw inputError('device is required');
|
|
125
136
|
if (args.product === undefined || args.product === null)
|
|
126
137
|
throw inputError('product is required (pass "" to unassign)');
|
|
@@ -133,7 +144,7 @@ async function toolDeviceSetProduct(args) {
|
|
|
133
144
|
}
|
|
134
145
|
|
|
135
146
|
async function toolDeviceDelete(args) {
|
|
136
|
-
const device = args.
|
|
147
|
+
const device = args.device_id;
|
|
137
148
|
if (!device) throw inputError('device is required');
|
|
138
149
|
const removed = await deleteDevice(device, args.user);
|
|
139
150
|
return {
|
|
@@ -182,10 +193,10 @@ export const tools = [
|
|
|
182
193
|
inputSchema: {
|
|
183
194
|
type: 'object',
|
|
184
195
|
properties: {
|
|
185
|
-
|
|
196
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
186
197
|
user: { type: 'string', description: 'API user' },
|
|
187
198
|
},
|
|
188
|
-
required: ['
|
|
199
|
+
required: ['device_id'],
|
|
189
200
|
},
|
|
190
201
|
handler: toolDeviceInfo,
|
|
191
202
|
},
|
|
@@ -196,14 +207,14 @@ export const tools = [
|
|
|
196
207
|
inputSchema: {
|
|
197
208
|
type: 'object',
|
|
198
209
|
properties: {
|
|
199
|
-
|
|
210
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
200
211
|
product: {
|
|
201
212
|
type: 'string',
|
|
202
213
|
description: 'Product ID to assign, or empty string "" to unassign.',
|
|
203
214
|
},
|
|
204
215
|
user: { type: 'string', description: 'API user' },
|
|
205
216
|
},
|
|
206
|
-
required: ['
|
|
217
|
+
required: ['device_id', 'product'],
|
|
207
218
|
},
|
|
208
219
|
handler: toolDeviceSetProduct,
|
|
209
220
|
},
|
|
@@ -214,10 +225,10 @@ export const tools = [
|
|
|
214
225
|
inputSchema: {
|
|
215
226
|
type: 'object',
|
|
216
227
|
properties: {
|
|
217
|
-
|
|
228
|
+
device_id: { type: 'string', description: 'Device ID to delete.' },
|
|
218
229
|
user: { type: 'string', description: 'API user' },
|
|
219
230
|
},
|
|
220
|
-
required: ['
|
|
231
|
+
required: ['device_id'],
|
|
221
232
|
},
|
|
222
233
|
handler: toolDeviceDelete,
|
|
223
234
|
},
|
package/lib/mcp/tools-exec.js
CHANGED
|
@@ -5,7 +5,7 @@ import { BYTES, TIMEOUTS } from '../constants.js';
|
|
|
5
5
|
import { getAPI } from './helpers.js';
|
|
6
6
|
|
|
7
7
|
async function toolExec(args) {
|
|
8
|
-
const { api } = getAPI(args.
|
|
8
|
+
const { api } = getAPI(args.device_id, args.user);
|
|
9
9
|
// Default guards against hung commands locking the session; callers
|
|
10
10
|
// override for known-long operations. Knob lives in lib/constants.js.
|
|
11
11
|
const timeout =
|
|
@@ -33,7 +33,7 @@ async function toolExec(args) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
async function toolUpdate(args) {
|
|
36
|
-
const { api: deviceApi } = getAPI(args.
|
|
36
|
+
const { api: deviceApi } = getAPI(args.device_id, args.user);
|
|
37
37
|
const payload = {
|
|
38
38
|
action: args.action,
|
|
39
39
|
channel: args.channel || 'latest',
|
|
@@ -46,7 +46,7 @@ async function toolUpdate(args) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
async function toolMonitoring(args) {
|
|
49
|
-
const device = args.
|
|
49
|
+
const device = args.device_id;
|
|
50
50
|
const user = args.user;
|
|
51
51
|
|
|
52
52
|
// Real-time: read the device's `monitoring` resource directly.
|
|
@@ -143,11 +143,11 @@ export const tools = [
|
|
|
143
143
|
type: 'object',
|
|
144
144
|
properties: {
|
|
145
145
|
command: { type: 'string', description: 'Shell command to execute' },
|
|
146
|
-
|
|
146
|
+
device_id: { type: 'string', description: 'Device ID (from thinr_devices)' },
|
|
147
147
|
timeout: { type: 'number', description: 'Timeout in seconds (default: 30)' },
|
|
148
148
|
user: { type: 'string', description: 'API user (for admin impersonation)' },
|
|
149
149
|
},
|
|
150
|
-
required: ['command', '
|
|
150
|
+
required: ['command', 'device_id'],
|
|
151
151
|
},
|
|
152
152
|
handler: toolExec,
|
|
153
153
|
},
|
|
@@ -157,7 +157,7 @@ export const tools = [
|
|
|
157
157
|
inputSchema: {
|
|
158
158
|
type: 'object',
|
|
159
159
|
properties: {
|
|
160
|
-
|
|
160
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
161
161
|
action: {
|
|
162
162
|
type: 'string',
|
|
163
163
|
description: '"check" to check for updates, "apply" to install the update',
|
|
@@ -165,7 +165,7 @@ export const tools = [
|
|
|
165
165
|
channel: { type: 'string', description: 'Update channel (default: "latest")' },
|
|
166
166
|
user: { type: 'string', description: 'API user' },
|
|
167
167
|
},
|
|
168
|
-
required: ['action', '
|
|
168
|
+
required: ['action', 'device_id'],
|
|
169
169
|
},
|
|
170
170
|
handler: toolUpdate,
|
|
171
171
|
},
|
|
@@ -185,14 +185,15 @@ Modes:
|
|
|
185
185
|
description:
|
|
186
186
|
'If true, get live data directly from the device. If false/omitted, query historical data from the monitoring bucket.',
|
|
187
187
|
},
|
|
188
|
-
|
|
188
|
+
device_id: {
|
|
189
189
|
type: 'string',
|
|
190
190
|
description:
|
|
191
191
|
'Device ID. Required for realtime. Omit for a one-row-per-device fleet view.',
|
|
192
192
|
},
|
|
193
193
|
items: {
|
|
194
194
|
type: 'number',
|
|
195
|
-
description:
|
|
195
|
+
description:
|
|
196
|
+
'Number of data points to return (default: 10). In multi-device mode set ≥ number of devices (e.g. 200) since each device contributes one row.',
|
|
196
197
|
},
|
|
197
198
|
minutes: {
|
|
198
199
|
type: 'number',
|
|
@@ -205,12 +206,18 @@ Modes:
|
|
|
205
206
|
},
|
|
206
207
|
sort: {
|
|
207
208
|
type: 'string',
|
|
208
|
-
description:
|
|
209
|
+
description:
|
|
210
|
+
'Sort order by timestamp: "asc" or "desc" (default: "desc"). Does NOT sort by metric — do that client-side on the result.',
|
|
211
|
+
},
|
|
212
|
+
agg: {
|
|
213
|
+
type: 'string',
|
|
214
|
+
description:
|
|
215
|
+
'Time-window size for windowed aggregation: "5m", "10m", "1h", "6h". Only meaningful with a specific device. For a per-device single value across the whole range, omit `agg` and pass `agg_type` alone.',
|
|
209
216
|
},
|
|
210
|
-
agg: { type: 'string', description: 'Time-window size for windowed aggregation: "5m", "10m", "1h", "6h". Only meaningful with a specific device. For a per-device single value across the whole range, omit `agg` and pass `agg_type` alone.' },
|
|
211
217
|
agg_type: {
|
|
212
218
|
type: 'string',
|
|
213
|
-
description:
|
|
219
|
+
description:
|
|
220
|
+
'Reducer applied to each group/window. Supported: "mean", "max", "min", "sum", "count", "first", "last", "median", "spread" (max-min), "stddev". Most common for monitoring: max, mean, min.',
|
|
214
221
|
},
|
|
215
222
|
fields: {
|
|
216
223
|
type: 'string',
|
|
@@ -5,7 +5,7 @@ import { getAPI } from './helpers.js';
|
|
|
5
5
|
import { inputError } from '../errors.js';
|
|
6
6
|
|
|
7
7
|
async function toolRead(args) {
|
|
8
|
-
const { api } = getAPI(args.
|
|
8
|
+
const { api } = getAPI(args.device_id, args.user);
|
|
9
9
|
const content = await api.readFile(args.path);
|
|
10
10
|
return {
|
|
11
11
|
content: [{ type: 'text', text: content.toString('utf8') }],
|
|
@@ -14,7 +14,7 @@ async function toolRead(args) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
async function toolWrite(args) {
|
|
17
|
-
const { api } = getAPI(args.
|
|
17
|
+
const { api } = getAPI(args.device_id, args.user);
|
|
18
18
|
await api.writeFile(args.path, Buffer.from(args.content, 'utf8'));
|
|
19
19
|
return {
|
|
20
20
|
content: [{ type: 'text', text: `Written ${args.content.length} bytes to ${args.path}` }],
|
|
@@ -23,8 +23,26 @@ async function toolWrite(args) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
async function toolLs(args) {
|
|
26
|
-
const { api } = getAPI(args.
|
|
27
|
-
const
|
|
26
|
+
const { api } = getAPI(args.device_id, args.user);
|
|
27
|
+
const requested = args.path || '/';
|
|
28
|
+
const entries = await api.listDir(requested, args.include_hidden || false);
|
|
29
|
+
|
|
30
|
+
// "/" is the agent's base directory, not the filesystem root, while every
|
|
31
|
+
// other absolute path is literal. Listing "/" on an agent based in
|
|
32
|
+
// /var/log and reading it as the root is an easy and wrong conclusion to
|
|
33
|
+
// draw, so say which directory these entries actually came from.
|
|
34
|
+
let header = '';
|
|
35
|
+
if (requested === '/') {
|
|
36
|
+
try {
|
|
37
|
+
const root = await api.info('/');
|
|
38
|
+
if (root?.path && root.path !== '/') {
|
|
39
|
+
header = `listing ${root.path} (the agent's base directory, which "/" resolves to)\n`;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Labelling is a convenience; a listing without it still stands.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
const lines = entries.map((e) => {
|
|
29
47
|
const type = e.type === 'directory' ? 'd' : '-';
|
|
30
48
|
const mode = e.mode || 'rwxr-xr-x';
|
|
@@ -32,13 +50,13 @@ async function toolLs(args) {
|
|
|
32
50
|
return `${type}${mode} ${size} ${e.name}`;
|
|
33
51
|
});
|
|
34
52
|
return {
|
|
35
|
-
content: [{ type: 'text', text: lines.join('\n') || '(empty directory)' }],
|
|
53
|
+
content: [{ type: 'text', text: header + (lines.join('\n') || '(empty directory)') }],
|
|
36
54
|
isError: false,
|
|
37
55
|
};
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
async function toolMkdir(args) {
|
|
41
|
-
const { api } = getAPI(args.
|
|
59
|
+
const { api } = getAPI(args.device_id, args.user);
|
|
42
60
|
await api.mkdir(args.path);
|
|
43
61
|
return {
|
|
44
62
|
content: [{ type: 'text', text: `Created directory: ${args.path}` }],
|
|
@@ -47,7 +65,7 @@ async function toolMkdir(args) {
|
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
async function toolRm(args) {
|
|
50
|
-
const { api } = getAPI(args.
|
|
68
|
+
const { api } = getAPI(args.device_id, args.user);
|
|
51
69
|
await api.delete(args.path, args.recursive !== false);
|
|
52
70
|
return {
|
|
53
71
|
content: [{ type: 'text', text: `Deleted: ${args.path}` }],
|
|
@@ -56,7 +74,7 @@ async function toolRm(args) {
|
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
async function toolMv(args) {
|
|
59
|
-
const { api } = getAPI(args.
|
|
77
|
+
const { api } = getAPI(args.device_id, args.user);
|
|
60
78
|
await api.move(args.source, args.destination, args.overwrite || false);
|
|
61
79
|
return {
|
|
62
80
|
content: [{ type: 'text', text: `Moved: ${args.source} → ${args.destination}` }],
|
|
@@ -75,7 +93,7 @@ async function toolPush(args) {
|
|
|
75
93
|
const remotePath = args.destination.endsWith('/')
|
|
76
94
|
? args.destination + basename(localPath)
|
|
77
95
|
: args.destination;
|
|
78
|
-
const { api } = getAPI(args.
|
|
96
|
+
const { api } = getAPI(args.device_id, args.user);
|
|
79
97
|
const content = await readLocalFile(localPath);
|
|
80
98
|
await api.writeFile(remotePath, content);
|
|
81
99
|
return {
|
|
@@ -99,7 +117,7 @@ async function toolPull(args) {
|
|
|
99
117
|
const localPath = args.destination.endsWith('/')
|
|
100
118
|
? resolvePath(args.destination, basename(args.source))
|
|
101
119
|
: resolvePath(args.destination);
|
|
102
|
-
const { api } = getAPI(args.
|
|
120
|
+
const { api } = getAPI(args.device_id, args.user);
|
|
103
121
|
const buf = await api.readFile(args.source);
|
|
104
122
|
await writeLocalFile(localPath, buf);
|
|
105
123
|
return {
|
|
@@ -121,10 +139,10 @@ export const tools = [
|
|
|
121
139
|
type: 'object',
|
|
122
140
|
properties: {
|
|
123
141
|
path: { type: 'string', description: 'Absolute file path on the device' },
|
|
124
|
-
|
|
142
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
125
143
|
user: { type: 'string', description: 'API user' },
|
|
126
144
|
},
|
|
127
|
-
required: ['path', '
|
|
145
|
+
required: ['path', 'device_id'],
|
|
128
146
|
},
|
|
129
147
|
handler: toolRead,
|
|
130
148
|
},
|
|
@@ -136,28 +154,32 @@ export const tools = [
|
|
|
136
154
|
properties: {
|
|
137
155
|
path: { type: 'string', description: 'Absolute file path on the device' },
|
|
138
156
|
content: { type: 'string', description: 'File content to write' },
|
|
139
|
-
|
|
157
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
140
158
|
user: { type: 'string', description: 'API user' },
|
|
141
159
|
},
|
|
142
|
-
required: ['path', 'content', '
|
|
160
|
+
required: ['path', 'content', 'device_id'],
|
|
143
161
|
},
|
|
144
162
|
handler: toolWrite,
|
|
145
163
|
},
|
|
146
164
|
{
|
|
147
165
|
name: 'thinr_ls',
|
|
148
|
-
description: `List directory contents on a remote device. Paths are absolute on the device filesystem (e.g., "/", "/
|
|
166
|
+
description: `List directory contents on a remote device. Paths are absolute on the device filesystem (e.g., "/etc", "/var/log"), with one exception: "/" on its own resolves to the agent's base directory (\`fs_base_path\` in thinr_device_info), which for a system install is usually /var/log. The listing says which directory it came from when the two differ. Nothing is confined to that directory — "/etc" reads the real /etc — so do not read a "/" listing as the root filesystem. Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths. Use include_hidden to see dotfiles.`,
|
|
149
167
|
inputSchema: {
|
|
150
168
|
type: 'object',
|
|
151
169
|
properties: {
|
|
152
|
-
path: {
|
|
170
|
+
path: {
|
|
171
|
+
type: 'string',
|
|
172
|
+
description:
|
|
173
|
+
'Directory path. Absolute and literal, except "/", which means the agent\'s base directory (default: "/").',
|
|
174
|
+
},
|
|
153
175
|
include_hidden: {
|
|
154
176
|
type: 'boolean',
|
|
155
177
|
description: 'Include hidden files (default: false)',
|
|
156
178
|
},
|
|
157
|
-
|
|
179
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
158
180
|
user: { type: 'string', description: 'API user' },
|
|
159
181
|
},
|
|
160
|
-
required: ['
|
|
182
|
+
required: ['device_id'],
|
|
161
183
|
},
|
|
162
184
|
handler: toolLs,
|
|
163
185
|
},
|
|
@@ -168,10 +190,10 @@ export const tools = [
|
|
|
168
190
|
type: 'object',
|
|
169
191
|
properties: {
|
|
170
192
|
path: { type: 'string', description: 'Directory path to create' },
|
|
171
|
-
|
|
193
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
172
194
|
user: { type: 'string', description: 'API user' },
|
|
173
195
|
},
|
|
174
|
-
required: ['path', '
|
|
196
|
+
required: ['path', 'device_id'],
|
|
175
197
|
},
|
|
176
198
|
handler: toolMkdir,
|
|
177
199
|
},
|
|
@@ -186,10 +208,10 @@ export const tools = [
|
|
|
186
208
|
type: 'boolean',
|
|
187
209
|
description: 'Delete directory contents recursively (default: true)',
|
|
188
210
|
},
|
|
189
|
-
|
|
211
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
190
212
|
user: { type: 'string', description: 'API user' },
|
|
191
213
|
},
|
|
192
|
-
required: ['path', '
|
|
214
|
+
required: ['path', 'device_id'],
|
|
193
215
|
},
|
|
194
216
|
handler: toolRm,
|
|
195
217
|
},
|
|
@@ -205,10 +227,10 @@ export const tools = [
|
|
|
205
227
|
type: 'boolean',
|
|
206
228
|
description: 'Overwrite destination if exists (default: false)',
|
|
207
229
|
},
|
|
208
|
-
|
|
230
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
209
231
|
user: { type: 'string', description: 'API user' },
|
|
210
232
|
},
|
|
211
|
-
required: ['source', 'destination', '
|
|
233
|
+
required: ['source', 'destination', 'device_id'],
|
|
212
234
|
},
|
|
213
235
|
handler: toolMv,
|
|
214
236
|
},
|
|
@@ -227,10 +249,10 @@ export const tools = [
|
|
|
227
249
|
description:
|
|
228
250
|
'Absolute path on the device. Trailing "/" keeps the source basename.',
|
|
229
251
|
},
|
|
230
|
-
|
|
252
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
231
253
|
user: { type: 'string', description: 'API user' },
|
|
232
254
|
},
|
|
233
|
-
required: ['source', 'destination', '
|
|
255
|
+
required: ['source', 'destination', 'device_id'],
|
|
234
256
|
},
|
|
235
257
|
handler: toolPush,
|
|
236
258
|
},
|
|
@@ -249,10 +271,10 @@ export const tools = [
|
|
|
249
271
|
description:
|
|
250
272
|
'Local file path (absolute or relative to the MCP server CWD). Trailing "/" keeps the source basename.',
|
|
251
273
|
},
|
|
252
|
-
|
|
274
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
253
275
|
user: { type: 'string', description: 'API user' },
|
|
254
276
|
},
|
|
255
|
-
required: ['source', 'destination', '
|
|
277
|
+
required: ['source', 'destination', 'device_id'],
|
|
256
278
|
},
|
|
257
279
|
handler: toolPull,
|
|
258
280
|
},
|
package/lib/mcp/tools-install.js
CHANGED
|
@@ -41,7 +41,7 @@ async function toolAgentInstallCommand(args) {
|
|
|
41
41
|
const url = `${scheme}://get.thinremote.io/${script}`;
|
|
42
42
|
|
|
43
43
|
const agentArgs = ['install', '--token', shQuote(config.token)];
|
|
44
|
-
if (args.
|
|
44
|
+
if (args.device_id) agentArgs.push('--device', shQuote(args.device_id));
|
|
45
45
|
if (args.product) agentArgs.push('--product', shQuote(args.product));
|
|
46
46
|
if (args.overwrite) agentArgs.push('--overwrite');
|
|
47
47
|
if (args.no_start) agentArgs.push('--no-start');
|
|
@@ -83,7 +83,7 @@ async function toolAgentInstallCommand(args) {
|
|
|
83
83
|
command,
|
|
84
84
|
'',
|
|
85
85
|
'Notes:',
|
|
86
|
-
|
|
86
|
+
"- The command embeds the active profile's API token — treat it as a credential.",
|
|
87
87
|
'- Installing the system service needs root. Pass `sudo: true` if the target host is not already root.',
|
|
88
88
|
'- The agent reads the target server from the JWT, so `--host` is not needed.',
|
|
89
89
|
];
|
|
@@ -106,10 +106,10 @@ export const tools = [
|
|
|
106
106
|
inputSchema: {
|
|
107
107
|
type: 'object',
|
|
108
108
|
properties: {
|
|
109
|
-
|
|
109
|
+
device_id: {
|
|
110
110
|
type: 'string',
|
|
111
111
|
description:
|
|
112
|
-
|
|
112
|
+
"Custom device identifier. Omit to let the agent default to the target host's hostname.",
|
|
113
113
|
},
|
|
114
114
|
product: {
|
|
115
115
|
type: 'string',
|
|
@@ -127,8 +127,7 @@ export const tools = [
|
|
|
127
127
|
},
|
|
128
128
|
no_verify_ssl: {
|
|
129
129
|
type: 'boolean',
|
|
130
|
-
description:
|
|
131
|
-
'Disable TLS certificate verification (for self-signed servers).',
|
|
130
|
+
description: 'Disable TLS certificate verification (for self-signed servers).',
|
|
132
131
|
},
|
|
133
132
|
channel: {
|
|
134
133
|
type: 'string',
|
|
@@ -170,15 +170,15 @@ Response carries a consolidated report: devices visited, ok / failed counts, and
|
|
|
170
170
|
},
|
|
171
171
|
target_product: {
|
|
172
172
|
type: 'string',
|
|
173
|
-
description:
|
|
173
|
+
description: "Override the playbook's target.product.",
|
|
174
174
|
},
|
|
175
175
|
target_group: {
|
|
176
176
|
type: 'string',
|
|
177
|
-
description:
|
|
177
|
+
description: "Override the playbook's target.group.",
|
|
178
178
|
},
|
|
179
179
|
target_devices: {
|
|
180
180
|
type: 'array',
|
|
181
|
-
description:
|
|
181
|
+
description: "Override the playbook's target.devices with an explicit list.",
|
|
182
182
|
},
|
|
183
183
|
concurrency: {
|
|
184
184
|
type: 'number',
|
|
@@ -187,7 +187,7 @@ Response carries a consolidated report: devices visited, ok / failed counts, and
|
|
|
187
187
|
fail_fast: {
|
|
188
188
|
type: 'boolean',
|
|
189
189
|
description:
|
|
190
|
-
|
|
190
|
+
"Stop dequeueing new devices on the first failure (default: playbook's own value).",
|
|
191
191
|
},
|
|
192
192
|
continue_on_error: {
|
|
193
193
|
type: 'boolean',
|
|
@@ -196,8 +196,7 @@ Response carries a consolidated report: devices visited, ok / failed counts, and
|
|
|
196
196
|
},
|
|
197
197
|
vars: {
|
|
198
198
|
type: 'object',
|
|
199
|
-
description:
|
|
200
|
-
'Merge into the playbook\'s `vars` block before substitution.',
|
|
199
|
+
description: "Merge into the playbook's `vars` block before substitution.",
|
|
201
200
|
},
|
|
202
201
|
user: {
|
|
203
202
|
type: 'string',
|
|
@@ -141,20 +141,14 @@ async function toolProductUpdate(args) {
|
|
|
141
141
|
|
|
142
142
|
const fields = Object.keys(patch);
|
|
143
143
|
if (fields.length === 0) {
|
|
144
|
-
throw inputError(
|
|
145
|
-
'at least one of name, description, enabled, icon must be provided',
|
|
146
|
-
);
|
|
144
|
+
throw inputError('at least one of name, description, enabled, icon must be provided');
|
|
147
145
|
}
|
|
148
146
|
|
|
149
147
|
await updateProduct(args.product, args.user, patch);
|
|
150
148
|
|
|
151
|
-
const summary = fields
|
|
152
|
-
.map((f) => (f === 'config' ? 'icon' : f))
|
|
153
|
-
.join(', ');
|
|
149
|
+
const summary = fields.map((f) => (f === 'config' ? 'icon' : f)).join(', ');
|
|
154
150
|
return {
|
|
155
|
-
content: [
|
|
156
|
-
{ type: 'text', text: `Updated product "${args.product}" (${summary}).` },
|
|
157
|
-
],
|
|
151
|
+
content: [{ type: 'text', text: `Updated product "${args.product}" (${summary}).` }],
|
|
158
152
|
isError: false,
|
|
159
153
|
};
|
|
160
154
|
}
|
|
@@ -430,7 +424,7 @@ async function toolProductPlaybookWrite(args) {
|
|
|
430
424
|
async function toolProductPlaybookRun(args) {
|
|
431
425
|
if (!args.product) throw inputError('product is required');
|
|
432
426
|
if (!args.name) throw inputError('name is required');
|
|
433
|
-
if (!args.
|
|
427
|
+
if (!args.device_id) throw inputError('device is required (single-device only)');
|
|
434
428
|
|
|
435
429
|
const entry = await findProductPlaybook(args.product, args.name, args.user);
|
|
436
430
|
if (!entry) {
|
|
@@ -453,7 +447,7 @@ async function toolProductPlaybookRun(args) {
|
|
|
453
447
|
throw inputError(err.message);
|
|
454
448
|
}
|
|
455
449
|
|
|
456
|
-
pb.target.devices = [args.
|
|
450
|
+
pb.target.devices = [args.device_id];
|
|
457
451
|
pb.target.product = args.product;
|
|
458
452
|
pb.target.group = null;
|
|
459
453
|
|
|
@@ -463,7 +457,7 @@ async function toolProductPlaybookRun(args) {
|
|
|
463
457
|
dry_run: true,
|
|
464
458
|
product: args.product,
|
|
465
459
|
name: args.name,
|
|
466
|
-
device: args.
|
|
460
|
+
device: args.device_id,
|
|
467
461
|
variables: listVariables(pb),
|
|
468
462
|
vars: resolvedScope,
|
|
469
463
|
steps: plan,
|
|
@@ -475,11 +469,10 @@ async function toolProductPlaybookRun(args) {
|
|
|
475
469
|
}
|
|
476
470
|
|
|
477
471
|
const all = await getDevices({ product: args.product }, args.user);
|
|
478
|
-
const device =
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
};
|
|
472
|
+
const device = all.find((d) => d.device === args.device_id) || {
|
|
473
|
+
device: args.device_id,
|
|
474
|
+
connection: { active: false },
|
|
475
|
+
};
|
|
483
476
|
|
|
484
477
|
const results = await runPlaybook(pb, [device], {
|
|
485
478
|
user: args.user,
|
|
@@ -492,7 +485,7 @@ async function toolProductPlaybookRun(args) {
|
|
|
492
485
|
const payload = {
|
|
493
486
|
product: args.product,
|
|
494
487
|
name: args.name,
|
|
495
|
-
device: args.
|
|
488
|
+
device: args.device_id,
|
|
496
489
|
mode: args.check ? 'check' : 'apply',
|
|
497
490
|
ok: !!result?.ok,
|
|
498
491
|
result,
|
|
@@ -508,9 +501,7 @@ async function toolProductPlaybookRollout(args) {
|
|
|
508
501
|
if (!args.name) throw inputError('name is required');
|
|
509
502
|
|
|
510
503
|
const batchSize =
|
|
511
|
-
Number.isFinite(args.batch_size) && args.batch_size > 0
|
|
512
|
-
? Math.floor(args.batch_size)
|
|
513
|
-
: 5;
|
|
504
|
+
Number.isFinite(args.batch_size) && args.batch_size > 0 ? Math.floor(args.batch_size) : 5;
|
|
514
505
|
const failureThreshold =
|
|
515
506
|
args.failure_threshold === undefined || args.failure_threshold === null
|
|
516
507
|
? DEFAULT_FAILURE_THRESHOLD
|
|
@@ -1455,9 +1446,10 @@ Pass \`dry_run: true\` to get the resolved plan without contacting the device. P
|
|
|
1455
1446
|
type: 'string',
|
|
1456
1447
|
description: 'Playbook name as registered in the product index.',
|
|
1457
1448
|
},
|
|
1458
|
-
|
|
1449
|
+
device_id: {
|
|
1459
1450
|
type: 'string',
|
|
1460
|
-
description:
|
|
1451
|
+
description:
|
|
1452
|
+
'Target device ID (single-device only). Pick any device of the product.',
|
|
1461
1453
|
},
|
|
1462
1454
|
vars: {
|
|
1463
1455
|
type: 'object',
|
|
@@ -1477,7 +1469,7 @@ Pass \`dry_run: true\` to get the resolved plan without contacting the device. P
|
|
|
1477
1469
|
},
|
|
1478
1470
|
user: { type: 'string', description: 'API user (admin impersonation).' },
|
|
1479
1471
|
},
|
|
1480
|
-
required: ['product', 'name', '
|
|
1472
|
+
required: ['product', 'name', 'device_id'],
|
|
1481
1473
|
},
|
|
1482
1474
|
handler: toolProductPlaybookRun,
|
|
1483
1475
|
},
|
package/lib/mcp/tools-profile.js
CHANGED
|
@@ -401,7 +401,8 @@ Gotchas:
|
|
|
401
401
|
},
|
|
402
402
|
enabled: {
|
|
403
403
|
type: 'boolean',
|
|
404
|
-
description:
|
|
404
|
+
description:
|
|
405
|
+
'Default: true. Set to false to stage a definition without activating it.',
|
|
405
406
|
},
|
|
406
407
|
user: { type: 'string', description: 'API user.' },
|
|
407
408
|
},
|
|
@@ -19,7 +19,7 @@ export const tools = [
|
|
|
19
19
|
{
|
|
20
20
|
name: 'thinr_profiles',
|
|
21
21
|
description:
|
|
22
|
-
'List configured CLI profiles (each profile targets a different ThinRemote server). Use the returned profile name as the optional `profile` parameter on any other tool to target that environment for a single call.',
|
|
22
|
+
'List configured CLI profiles (each profile targets a different ThinRemote server). Use the returned profile name as the optional `profile` parameter on any other tool to target that environment for a single call.\n\nProfiles are not accounts. Every tool also takes an optional `user`, which addresses another account on the same server when the profile has admin rights — so a device belonging to `demo` is reached with `user: "demo"`, not by looking for a profile of that name.',
|
|
23
23
|
inputSchema: {
|
|
24
24
|
type: 'object',
|
|
25
25
|
properties: {},
|
|
@@ -3,7 +3,7 @@ import { getDeviceProperties, getDeviceProperty, setDeviceProperty } from '../pr
|
|
|
3
3
|
import { inputError } from '../errors.js';
|
|
4
4
|
|
|
5
5
|
async function toolPropertyGet(args) {
|
|
6
|
-
const device = args.
|
|
6
|
+
const device = args.device_id;
|
|
7
7
|
if (!device)
|
|
8
8
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
9
9
|
if (args.property) {
|
|
@@ -22,7 +22,7 @@ async function toolPropertyGet(args) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
async function toolPropertySet(args) {
|
|
25
|
-
const device = args.
|
|
25
|
+
const device = args.device_id;
|
|
26
26
|
if (!device)
|
|
27
27
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
28
28
|
if (!args.property) throw inputError('property is required');
|
|
@@ -51,10 +51,10 @@ export const tools = [
|
|
|
51
51
|
description:
|
|
52
52
|
'Property name. Omit to list the names of all properties on the device.',
|
|
53
53
|
},
|
|
54
|
-
|
|
54
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
55
55
|
user: { type: 'string', description: 'API user' },
|
|
56
56
|
},
|
|
57
|
-
required: ['
|
|
57
|
+
required: ['device_id'],
|
|
58
58
|
},
|
|
59
59
|
handler: toolPropertyGet,
|
|
60
60
|
},
|
|
@@ -69,10 +69,10 @@ export const tools = [
|
|
|
69
69
|
description:
|
|
70
70
|
'Any JSON-serialisable value (string, number, boolean, object, array).',
|
|
71
71
|
},
|
|
72
|
-
|
|
72
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
73
73
|
user: { type: 'string', description: 'API user' },
|
|
74
74
|
},
|
|
75
|
-
required: ['property', 'value', '
|
|
75
|
+
required: ['property', 'value', 'device_id'],
|
|
76
76
|
},
|
|
77
77
|
handler: toolPropertySet,
|
|
78
78
|
},
|
|
@@ -3,7 +3,7 @@ import { callDeviceResource, listDeviceResourcesWithSchemas } from '../resource.
|
|
|
3
3
|
import { inputError } from '../errors.js';
|
|
4
4
|
|
|
5
5
|
async function toolResourceList(args) {
|
|
6
|
-
const device = args.
|
|
6
|
+
const device = args.device_id;
|
|
7
7
|
if (!device)
|
|
8
8
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
9
9
|
const entries = await listDeviceResourcesWithSchemas(device, { user: args.user });
|
|
@@ -22,7 +22,7 @@ async function toolResourceList(args) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
async function toolResourceCall(args) {
|
|
25
|
-
const device = args.
|
|
25
|
+
const device = args.device_id;
|
|
26
26
|
if (!device)
|
|
27
27
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
28
28
|
if (!args.resource) throw inputError('resource is required');
|
|
@@ -47,10 +47,10 @@ export const tools = [
|
|
|
47
47
|
inputSchema: {
|
|
48
48
|
type: 'object',
|
|
49
49
|
properties: {
|
|
50
|
-
|
|
50
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
51
51
|
user: { type: 'string', description: 'API user' },
|
|
52
52
|
},
|
|
53
|
-
required: ['
|
|
53
|
+
required: ['device_id'],
|
|
54
54
|
},
|
|
55
55
|
handler: toolResourceList,
|
|
56
56
|
},
|
|
@@ -68,10 +68,10 @@ export const tools = [
|
|
|
68
68
|
description:
|
|
69
69
|
'JSON object with the input parameters declared by the resource. Omit or pass {} for input-less resources.',
|
|
70
70
|
},
|
|
71
|
-
|
|
71
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
72
72
|
user: { type: 'string', description: 'API user' },
|
|
73
73
|
},
|
|
74
|
-
required: ['resource', '
|
|
74
|
+
required: ['resource', 'device_id'],
|
|
75
75
|
},
|
|
76
76
|
handler: toolResourceCall,
|
|
77
77
|
},
|
package/lib/mcp/tools-scripts.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { callDeviceResource, readDeviceResource } from '../resource.js';
|
|
2
|
+
import { callDeviceResource, getDeviceResources, readDeviceResource } from '../resource.js';
|
|
3
3
|
import { inputError } from '../errors.js';
|
|
4
4
|
import { getAPI } from './helpers.js';
|
|
5
5
|
|
|
6
6
|
async function toolScriptList(args) {
|
|
7
|
-
const device = args.
|
|
7
|
+
const device = args.device_id;
|
|
8
8
|
if (!device)
|
|
9
9
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
10
10
|
const info = await readDeviceResource(device, '$scripts/info', { user: args.user });
|
|
@@ -25,7 +25,7 @@ async function toolScriptList(args) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
async function toolScriptWrite(args) {
|
|
28
|
-
const device = args.
|
|
28
|
+
const device = args.device_id;
|
|
29
29
|
if (!device)
|
|
30
30
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
31
31
|
if (!args.name) throw inputError('name is required');
|
|
@@ -45,6 +45,27 @@ async function toolScriptWrite(args) {
|
|
|
45
45
|
}
|
|
46
46
|
const scriptPath = `${baseDir}/${args.name}`;
|
|
47
47
|
|
|
48
|
+
// The agent registers scripts at the root of the device's resource
|
|
49
|
+
// namespace, so a script named after a built-in resource replaces it and
|
|
50
|
+
// the device silently stops answering the real one. Refuse unless the
|
|
51
|
+
// caller says that is the intent. A name that already belongs to one of
|
|
52
|
+
// this device's own scripts is an update, not a collision.
|
|
53
|
+
const registeredName = args.name.replace(/\.[^./]+$/, '');
|
|
54
|
+
if (!args.force) {
|
|
55
|
+
const [native, current] = await Promise.all([
|
|
56
|
+
getDeviceResources(device, { user: args.user }).catch(() => ({})),
|
|
57
|
+
Promise.resolve(Array.isArray(info?.scripts) ? info.scripts : []),
|
|
58
|
+
]);
|
|
59
|
+
const isOwnScript = current.some((s) => s.name === registeredName);
|
|
60
|
+
if (!isOwnScript && Object.prototype.hasOwnProperty.call(native, registeredName)) {
|
|
61
|
+
throw inputError(
|
|
62
|
+
`"${registeredName}" is already a resource on this device, and installing this ` +
|
|
63
|
+
`script would replace it. Rename the script, or pass force: true if replacing ` +
|
|
64
|
+
`it is what you want.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
await deviceApi.writeFile(scriptPath, args.content, true);
|
|
49
70
|
|
|
50
71
|
const chmod = await deviceApi.exec(`chmod +x ${JSON.stringify(scriptPath)}`, 10);
|
|
@@ -53,9 +74,6 @@ async function toolScriptWrite(args) {
|
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
const reloaded = await callDeviceResource(device, '$scripts/reload', {}, { user: args.user });
|
|
56
|
-
// The agent registers resources by the file's stem, so "battery.sh"
|
|
57
|
-
// becomes scripts/battery. Match the same way to echo back the schema.
|
|
58
|
-
const registeredName = args.name.replace(/\.[^./]+$/, '');
|
|
59
77
|
const found = Array.isArray(reloaded?.scripts)
|
|
60
78
|
? reloaded.scripts.find((s) => s.name === registeredName)
|
|
61
79
|
: null;
|
|
@@ -76,7 +94,7 @@ async function toolScriptWrite(args) {
|
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
async function toolScriptDelete(args) {
|
|
79
|
-
const device = args.
|
|
97
|
+
const device = args.device_id;
|
|
80
98
|
if (!device)
|
|
81
99
|
throw inputError('No device specified. Use thinr_devices to list available devices.');
|
|
82
100
|
if (!args.name) throw inputError('name is required');
|
|
@@ -90,7 +108,21 @@ async function toolScriptDelete(args) {
|
|
|
90
108
|
if (!/^[A-Za-z0-9._-]+$/.test(args.name)) {
|
|
91
109
|
throw inputError(`Invalid script name "${args.name}"`);
|
|
92
110
|
}
|
|
93
|
-
|
|
111
|
+
|
|
112
|
+
// Callers name a script either way round: `battery` as it appears in the
|
|
113
|
+
// resource list, or `battery.sh` as it sits on disk. Resolve against what
|
|
114
|
+
// the agent reports so both land on the same file, and say what is there
|
|
115
|
+
// when neither matches instead of letting a 404 through.
|
|
116
|
+
const scripts = Array.isArray(info?.scripts) ? info.scripts : [];
|
|
117
|
+
const stem = args.name.replace(/\.[^./]+$/, '');
|
|
118
|
+
const match = scripts.find((s) => s.name === stem || s.path?.endsWith(`/${args.name}`));
|
|
119
|
+
if (!match) {
|
|
120
|
+
const registered = scripts.map((s) => s.name).join(', ') || 'none';
|
|
121
|
+
throw inputError(
|
|
122
|
+
`No script named "${args.name}" on this device. Registered: ${registered}.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const scriptPath = match.path || `${baseDir}/${args.name}`;
|
|
94
126
|
|
|
95
127
|
await deviceApi.delete(scriptPath, false);
|
|
96
128
|
const reloaded = await callDeviceResource(device, '$scripts/reload', {}, { user: args.user });
|
|
@@ -99,7 +131,7 @@ async function toolScriptDelete(args) {
|
|
|
99
131
|
content: [
|
|
100
132
|
{
|
|
101
133
|
type: 'text',
|
|
102
|
-
text: `Removed script "${
|
|
134
|
+
text: `Removed script "${match.name}" (${scriptPath}). ${remaining} script(s) remain registered.`,
|
|
103
135
|
},
|
|
104
136
|
],
|
|
105
137
|
isError: false,
|
|
@@ -113,10 +145,10 @@ export const tools = [
|
|
|
113
145
|
inputSchema: {
|
|
114
146
|
type: 'object',
|
|
115
147
|
properties: {
|
|
116
|
-
|
|
148
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
117
149
|
user: { type: 'string', description: 'API user' },
|
|
118
150
|
},
|
|
119
|
-
required: ['
|
|
151
|
+
required: ['device_id'],
|
|
120
152
|
},
|
|
121
153
|
handler: toolScriptList,
|
|
122
154
|
},
|
|
@@ -124,12 +156,15 @@ export const tools = [
|
|
|
124
156
|
name: 'thinr_script_write',
|
|
125
157
|
description: `Install or replace a custom script on the device and reload the scripts extension so the new resource is immediately callable.
|
|
126
158
|
|
|
127
|
-
Scripts turn an ad-hoc operation into a reusable, typed, discoverable resource on the device. Prefer this over thinr_exec when you want to formalise a recurring task (read sensors, rotate a log, run a backup, collect a health report) so it becomes a persistent resource with an input/output schema that future tool calls can introspect and invoke via thinr_resource_call. A script named \`battery.sh\` is registered as the resource \`battery
|
|
159
|
+
Scripts turn an ad-hoc operation into a reusable, typed, discoverable resource on the device. Prefer this over thinr_exec when you want to formalise a recurring task (read sensors, rotate a log, run a backup, collect a health report) so it becomes a persistent resource with an input/output schema that future tool calls can introspect and invoke via thinr_resource_call. A script named \`battery.sh\` is registered as the resource \`battery\`. A name that already belongs to a built-in resource (\`monitoring\`, \`system\`, \`cmd\`, \`update\`, \`agent\`) would replace it and leave the device no longer answering the real one, so this tool refuses that unless you pass \`force: true\`.
|
|
160
|
+
|
|
161
|
+
The script runs with the agent's privileges, which for a system install means root. Installing one grants permanent remote execution of that code on the device; treat it like adding a service, not like running a command.
|
|
128
162
|
|
|
129
163
|
Script contract:
|
|
130
164
|
- Must be executable code (any interpreter works — add a shebang like \`#!/bin/bash\` or \`#!/usr/bin/env python3\`).
|
|
131
165
|
- When invoked with \`--describe\`, it should print a JSON object with \`input\` and/or \`output\` sample objects on stdout (e.g. \`{"input": {"path": ""}, "output": {"size": 0, "mtime": 0}}\`). These populate the resource's schema.
|
|
132
166
|
- When invoked normally, the full input JSON object is passed on stdin; stdout is parsed as JSON. If stdout is a JSON object, its keys become the output fields; otherwise the value is exposed under \`output\`. Non-zero exit codes surface as errors.
|
|
167
|
+
- Emit JSON that matches the \`--describe\` schema. Interpolating values you do not control (hostnames, command output, file contents) into a hand-built string breaks on the first quote or backslash; use \`jq\`, or \`python3 -c\` with \`json.dumps\` and the value passed through the environment. Output landing under \`output\` means the JSON was invalid, not that the raw form was accepted.
|
|
133
168
|
|
|
134
169
|
This tool writes the script, makes it executable, and calls the agent's \`$scripts/reload\` so the change takes effect without restarting the agent.`,
|
|
135
170
|
inputSchema: {
|
|
@@ -140,14 +175,19 @@ This tool writes the script, makes it executable, and calls the agent's \`$scrip
|
|
|
140
175
|
description:
|
|
141
176
|
'Script filename. The file extension (e.g. .sh, .py) is stripped to form the resource name, so battery.sh registers as the resource "battery".',
|
|
142
177
|
},
|
|
178
|
+
force: {
|
|
179
|
+
type: 'boolean',
|
|
180
|
+
description:
|
|
181
|
+
"Install even when the resulting resource name replaces one of the device's built-in resources.",
|
|
182
|
+
},
|
|
143
183
|
content: {
|
|
144
184
|
type: 'string',
|
|
145
185
|
description: 'Full script source code, including shebang.',
|
|
146
186
|
},
|
|
147
|
-
|
|
187
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
148
188
|
user: { type: 'string', description: 'API user' },
|
|
149
189
|
},
|
|
150
|
-
required: ['name', 'content', '
|
|
190
|
+
required: ['name', 'content', 'device_id'],
|
|
151
191
|
},
|
|
152
192
|
handler: toolScriptWrite,
|
|
153
193
|
},
|
|
@@ -162,10 +202,10 @@ This tool writes the script, makes it executable, and calls the agent's \`$scrip
|
|
|
162
202
|
description:
|
|
163
203
|
'Name of the script to remove (must match the file basename, without extension).',
|
|
164
204
|
},
|
|
165
|
-
|
|
205
|
+
device_id: { type: 'string', description: 'Device ID' },
|
|
166
206
|
user: { type: 'string', description: 'API user' },
|
|
167
207
|
},
|
|
168
|
-
required: ['name', '
|
|
208
|
+
required: ['name', 'device_id'],
|
|
169
209
|
},
|
|
170
210
|
handler: toolScriptDelete,
|
|
171
211
|
},
|
package/lib/mcp/tools-tokens.js
CHANGED
|
@@ -115,13 +115,11 @@ async function toolTokenDelete(args) {
|
|
|
115
115
|
// ─── Device-level tokens ────────────────────────────────────────────
|
|
116
116
|
|
|
117
117
|
async function toolDeviceTokenList(args) {
|
|
118
|
-
if (!args.
|
|
119
|
-
const tokens = await listDeviceTokens(args.
|
|
118
|
+
if (!args.device_id) throw inputError('device is required');
|
|
119
|
+
const tokens = await listDeviceTokens(args.device_id, args.user);
|
|
120
120
|
if (tokens.length === 0) {
|
|
121
121
|
return {
|
|
122
|
-
content: [
|
|
123
|
-
{ type: 'text', text: `No device tokens on device "${args.device}".` },
|
|
124
|
-
],
|
|
122
|
+
content: [{ type: 'text', text: `No device tokens on device "${args.device_id}".` }],
|
|
125
123
|
isError: false,
|
|
126
124
|
};
|
|
127
125
|
}
|
|
@@ -129,7 +127,7 @@ async function toolDeviceTokenList(args) {
|
|
|
129
127
|
content: [
|
|
130
128
|
{
|
|
131
129
|
type: 'text',
|
|
132
|
-
text: `${tokens.length} device token(s) on "${args.
|
|
130
|
+
text: `${tokens.length} device token(s) on "${args.device_id}":\n${JSON.stringify(tokens, null, 2)}`,
|
|
133
131
|
},
|
|
134
132
|
],
|
|
135
133
|
isError: false,
|
|
@@ -137,11 +135,11 @@ async function toolDeviceTokenList(args) {
|
|
|
137
135
|
}
|
|
138
136
|
|
|
139
137
|
async function toolDeviceTokenCreate(args) {
|
|
140
|
-
if (!args.
|
|
138
|
+
if (!args.device_id) throw inputError('device is required');
|
|
141
139
|
if (!args.token_name) throw inputError('token_name is required');
|
|
142
140
|
|
|
143
141
|
const doc = await createDeviceToken(
|
|
144
|
-
args.
|
|
142
|
+
args.device_id,
|
|
145
143
|
{
|
|
146
144
|
token_name: args.token_name,
|
|
147
145
|
token_resources: args.token_resources,
|
|
@@ -153,7 +151,7 @@ async function toolDeviceTokenCreate(args) {
|
|
|
153
151
|
content: [
|
|
154
152
|
{
|
|
155
153
|
type: 'text',
|
|
156
|
-
text: `Created device token "${args.token_name}" on "${args.
|
|
154
|
+
text: `Created device token "${args.token_name}" on "${args.device_id}".\n${JSON.stringify(doc, null, 2)}`,
|
|
157
155
|
},
|
|
158
156
|
],
|
|
159
157
|
isError: false,
|
|
@@ -161,16 +159,16 @@ async function toolDeviceTokenCreate(args) {
|
|
|
161
159
|
}
|
|
162
160
|
|
|
163
161
|
async function toolDeviceTokenDelete(args) {
|
|
164
|
-
if (!args.
|
|
162
|
+
if (!args.device_id) throw inputError('device is required');
|
|
165
163
|
if (!args.token_id) throw inputError('token_id is required');
|
|
166
|
-
const removed = await deleteDeviceToken(args.
|
|
164
|
+
const removed = await deleteDeviceToken(args.device_id, args.token_id, args.user);
|
|
167
165
|
return {
|
|
168
166
|
content: [
|
|
169
167
|
{
|
|
170
168
|
type: 'text',
|
|
171
169
|
text: removed
|
|
172
|
-
? `Deleted device token "${args.token_id}" from "${args.
|
|
173
|
-
: `Device token "${args.token_id}" was not configured on "${args.
|
|
170
|
+
? `Deleted device token "${args.token_id}" from "${args.device_id}".`
|
|
171
|
+
: `Device token "${args.token_id}" was not configured on "${args.device_id}".`,
|
|
174
172
|
},
|
|
175
173
|
],
|
|
176
174
|
isError: false,
|
|
@@ -318,10 +316,10 @@ ${EXPIRY_NOTE}`,
|
|
|
318
316
|
inputSchema: {
|
|
319
317
|
type: 'object',
|
|
320
318
|
properties: {
|
|
321
|
-
|
|
319
|
+
device_id: { type: 'string', description: 'Device ID.' },
|
|
322
320
|
user: { type: 'string', description: 'API user.' },
|
|
323
321
|
},
|
|
324
|
-
required: ['
|
|
322
|
+
required: ['device_id'],
|
|
325
323
|
},
|
|
326
324
|
handler: toolDeviceTokenList,
|
|
327
325
|
},
|
|
@@ -336,7 +334,7 @@ Example: a token that lets a remote script call only the \`monitoring\` and \`re
|
|
|
336
334
|
inputSchema: {
|
|
337
335
|
type: 'object',
|
|
338
336
|
properties: {
|
|
339
|
-
|
|
337
|
+
device_id: { type: 'string', description: 'Device ID.' },
|
|
340
338
|
token_name: { type: 'string', description: 'Display name for the new token.' },
|
|
341
339
|
token_resources: {
|
|
342
340
|
type: 'array',
|
|
@@ -350,7 +348,7 @@ Example: a token that lets a remote script call only the \`monitoring\` and \`re
|
|
|
350
348
|
},
|
|
351
349
|
user: { type: 'string', description: 'API user.' },
|
|
352
350
|
},
|
|
353
|
-
required: ['
|
|
351
|
+
required: ['device_id', 'token_name'],
|
|
354
352
|
},
|
|
355
353
|
handler: toolDeviceTokenCreate,
|
|
356
354
|
},
|
|
@@ -360,11 +358,11 @@ Example: a token that lets a remote script call only the \`monitoring\` and \`re
|
|
|
360
358
|
inputSchema: {
|
|
361
359
|
type: 'object',
|
|
362
360
|
properties: {
|
|
363
|
-
|
|
361
|
+
device_id: { type: 'string', description: 'Device ID.' },
|
|
364
362
|
token_id: { type: 'string', description: 'Token id to remove.' },
|
|
365
363
|
user: { type: 'string', description: 'API user.' },
|
|
366
364
|
},
|
|
367
|
-
required: ['
|
|
365
|
+
required: ['device_id', 'token_id'],
|
|
368
366
|
},
|
|
369
367
|
handler: toolDeviceTokenDelete,
|
|
370
368
|
},
|