@thinremote/thinr-cli 1.3.4 → 1.4.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +117 -123
  3. package/bin/thinr.js +43 -105
  4. package/commands/_shared.js +10 -90
  5. package/commands/device/_shared.js +2 -2
  6. package/commands/device/exec.js +2 -2
  7. package/commands/device/fs.js +2 -2
  8. package/commands/device/index.js +5 -19
  9. package/commands/device/logs.js +4 -4
  10. package/commands/device/status.js +1 -1
  11. package/commands/device/transfer.js +2 -2
  12. package/commands/device/update.js +8 -2
  13. package/commands/fleet/upgrade.js +3 -3
  14. package/commands/playbook/run.js +2 -2
  15. package/commands/playbook/validate.js +2 -2
  16. package/commands/product/_shared.js +1 -1
  17. package/commands/product/exec.js +2 -2
  18. package/commands/product/fs.js +2 -2
  19. package/commands/product/index.js +4 -11
  20. package/commands/product/logs.js +2 -2
  21. package/commands/product/metric.js +2 -2
  22. package/commands/product/playbook.js +5 -5
  23. package/commands/product/property.js +4 -4
  24. package/commands/product/push.js +2 -2
  25. package/commands/product/resource.js +4 -4
  26. package/commands/product/status.js +3 -3
  27. package/dist/dashboard.js +12 -12
  28. package/docs/guides/thinremote-metrics-logs.md +59 -0
  29. package/docs/guides/thinremote-playbooks.md +133 -0
  30. package/docs/guides/thinremote-scripts.md +115 -0
  31. package/docs/guides/thinremote.md +116 -0
  32. package/lib/command-stream.js +3 -2
  33. package/lib/console.js +6 -6
  34. package/lib/constants.js +1 -19
  35. package/lib/dashboard/event-stream.js +5 -5
  36. package/lib/device-api.js +4 -4
  37. package/lib/guides.js +41 -0
  38. package/lib/identity.js +75 -0
  39. package/lib/local-files.js +54 -0
  40. package/lib/mcp/{tools-install.js → agent-install.js} +12 -61
  41. package/lib/mcp/helpers.js +5 -25
  42. package/lib/mcp/playbook-ops.js +393 -0
  43. package/lib/mcp/registry.js +9 -43
  44. package/lib/mcp/resource-hooks.js +53 -0
  45. package/lib/mcp/resource-types.js +857 -0
  46. package/lib/mcp/tools-exec.js +217 -177
  47. package/lib/monitoring.js +3 -3
  48. package/lib/playbook/actions.js +30 -18
  49. package/lib/playbook/expression.js +1 -4
  50. package/lib/playbook/fleet.js +4 -1
  51. package/lib/playbook/loader.js +16 -8
  52. package/lib/playbook/runner.js +40 -8
  53. package/lib/playbook/schema.js +108 -34
  54. package/lib/product/log-presets.js +1 -1
  55. package/lib/product/logs.js +2 -2
  56. package/lib/product-orchestrator.js +14 -9
  57. package/lib/product.js +29 -227
  58. package/lib/proxy.js +4 -4
  59. package/lib/status.js +13 -5
  60. package/package.json +4 -2
  61. package/commands/device/delete.js +0 -53
  62. package/commands/device/list.js +0 -55
  63. package/commands/device/property.js +0 -52
  64. package/commands/device/resource.js +0 -52
  65. package/commands/logout.js +0 -47
  66. package/commands/product/list.js +0 -86
  67. package/commands/product/props.js +0 -125
  68. package/commands/profile.js +0 -173
  69. package/lib/alarms.js +0 -223
  70. package/lib/api.js +0 -89
  71. package/lib/auth.js +0 -428
  72. package/lib/bucket.js +0 -82
  73. package/lib/concurrency.js +0 -52
  74. package/lib/config.js +0 -230
  75. package/lib/devices.js +0 -60
  76. package/lib/errors.js +0 -65
  77. package/lib/format.js +0 -114
  78. package/lib/mcp/server.js +0 -67
  79. package/lib/mcp/tools-alarms.js +0 -507
  80. package/lib/mcp/tools-buckets.js +0 -99
  81. package/lib/mcp/tools-devices.js +0 -235
  82. package/lib/mcp/tools-filesystem.js +0 -281
  83. package/lib/mcp/tools-logs.js +0 -225
  84. package/lib/mcp/tools-playbook.js +0 -210
  85. package/lib/mcp/tools-products.js +0 -1557
  86. package/lib/mcp/tools-profile.js +0 -635
  87. package/lib/mcp/tools-profiles.js +0 -30
  88. package/lib/mcp/tools-properties.js +0 -79
  89. package/lib/mcp/tools-resources.js +0 -78
  90. package/lib/mcp/tools-scripts.js +0 -212
  91. package/lib/mcp/tools-tokens.js +0 -369
  92. package/lib/output.js +0 -114
  93. package/lib/paths.js +0 -32
  94. package/lib/profile.js +0 -657
  95. package/lib/property.js +0 -75
  96. package/lib/resource.js +0 -151
  97. package/lib/storage.js +0 -117
  98. package/lib/tokens.js +0 -366
@@ -1,235 +0,0 @@
1
- // @ts-check
2
- import { getDevices, getDevice, deleteDevice } from '../devices.js';
3
- import { setDeviceProduct } from '../product.js';
4
- import { inputError } from '../errors.js';
5
- import { BYTES } from '../constants.js';
6
- import { getAPI } from './helpers.js';
7
-
8
- async function toolDevices(args) {
9
- const query = args.query ? String(args.query).trim() : '';
10
- const filter = query ? { name: query } : {};
11
- const devices = await getDevices(filter, args.user);
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
-
24
- const renderLine = (d) => {
25
- const status = d.connection?.active ? 'online ' : 'offline';
26
- const label = d.name ? ` (${d.name})` : '';
27
- const product = d.type && d.type !== 'Generic' ? ` [${d.type}]` : '';
28
- return `${status} ${d.device}${label}${product}${since(d)}`;
29
- };
30
-
31
- if (!query) {
32
- return {
33
- content: [
34
- { type: 'text', text: devices.map(renderLine).join('\n') || 'No devices found' },
35
- ],
36
- isError: false,
37
- };
38
- }
39
-
40
- const limit = Number.isFinite(args.limit) && args.limit > 0 ? Math.floor(args.limit) : 20;
41
- const limited = devices.slice(0, limit);
42
- const header =
43
- devices.length === 0
44
- ? `No devices match "${query}"`
45
- : `${devices.length} match(es) for "${query}"${devices.length > limit ? ` — showing first ${limit}` : ''}:`;
46
- return {
47
- content: [{ type: 'text', text: [header, ...limited.map(renderLine)].join('\n') }],
48
- isError: false,
49
- };
50
- }
51
-
52
- async function toolDeviceInfo(args) {
53
- const { api: deviceApi } = getAPI(args.device_id, args.user);
54
- const parts = [];
55
-
56
- // Server-side record first — surfaces the assigned product,
57
- // asset group, description, etc. that the device's own agent
58
- // can't see. Best effort: don't fail the whole tool if the
59
- // record can't be read.
60
- try {
61
- const record = await getDevice(args.device_id, args.user);
62
- const meta = [];
63
- if (record.device) meta.push(`device: ${record.device}`);
64
- if (record.name) meta.push(`name: ${record.name}`);
65
- if (record.product) meta.push(`product: ${record.product}`);
66
- if (record.asset_group) meta.push(`asset_group: ${record.asset_group}`);
67
- if (record.description) meta.push(`description: ${record.description}`);
68
- if (record.connection) {
69
- const c = record.connection;
70
- meta.push(
71
- `connection: ${c.active ? 'online' : 'offline'}${c.ip_address ? ` from ${c.ip_address}` : ''}`,
72
- );
73
- }
74
- if (meta.length) {
75
- parts.push('--- Server record ---');
76
- parts.push(...meta);
77
- parts.push('');
78
- }
79
- } catch {}
80
-
81
- try {
82
- const sysInfo = await deviceApi.getResource('system_info');
83
- parts.push('--- System info ---');
84
- parts.push(JSON.stringify(sysInfo, null, 2));
85
- } catch {
86
- const info = await deviceApi.exec(
87
- 'echo "hostname=$(hostname);os=$(uname -s);arch=$(uname -m);kernel=$(uname -r);user=$(whoami);home=$HOME;uptime=$(uptime -p 2>/dev/null || uptime)"',
88
- 10,
89
- );
90
- parts.push(info.stdout || '');
91
- }
92
-
93
- try {
94
- const mon = await deviceApi.getResource('monitoring');
95
- parts.push('\n--- Real-time monitoring ---');
96
- if (mon.cpu)
97
- parts.push(
98
- `CPU: ${mon.cpu.usage?.toFixed(1)}% (${mon.cpu.cores} cores, ${mon.cpu.temperature?.toFixed(1)}°C)`,
99
- );
100
- if (mon.memory)
101
- parts.push(
102
- `Memory: ${mon.memory.usage?.toFixed(1)}% (${(mon.memory.total / BYTES.GB).toFixed(1)}GB total)`,
103
- );
104
- if (mon.disk?.root)
105
- parts.push(
106
- `Disk: ${mon.disk.root.usage?.toFixed(1)}% (${(mon.disk.root.total / BYTES.GB).toFixed(0)}GB total)`,
107
- );
108
- if (mon.network)
109
- parts.push(
110
- `Network: rx=${(mon.network.rx_rate / 1024).toFixed(1)}KB/s tx=${(mon.network.tx_rate / 1024).toFixed(1)}KB/s`,
111
- );
112
- if (mon.load)
113
- parts.push(
114
- `Load: ${mon.load['1m']?.toFixed(2)} / ${mon.load['5m']?.toFixed(2)} / ${mon.load['15m']?.toFixed(2)}`,
115
- );
116
- if (mon.uptime)
117
- parts.push(
118
- `Uptime: ${Math.floor(mon.uptime / 86400)}d ${Math.floor((mon.uptime % 86400) / 3600)}h`,
119
- );
120
- } catch {}
121
-
122
- try {
123
- const rootInfo = await deviceApi.info('/');
124
- parts.push(`\nfs_base_path=${rootInfo.path || '/'}`);
125
- } catch {}
126
-
127
- return {
128
- content: [{ type: 'text', text: parts.join('\n') }],
129
- isError: false,
130
- };
131
- }
132
-
133
- async function toolDeviceSetProduct(args) {
134
- const device = args.device_id;
135
- if (!device) throw inputError('device is required');
136
- if (args.product === undefined || args.product === null)
137
- throw inputError('product is required (pass "" to unassign)');
138
- await setDeviceProduct(device, args.product, args.user);
139
- const label =
140
- args.product === ''
141
- ? `Unassigned ${device} from its product.`
142
- : `Assigned ${device} → product "${args.product}".`;
143
- return { content: [{ type: 'text', text: label }], isError: false };
144
- }
145
-
146
- async function toolDeviceDelete(args) {
147
- const device = args.device_id;
148
- if (!device) throw inputError('device is required');
149
- const removed = await deleteDevice(device, args.user);
150
- return {
151
- content: [
152
- {
153
- type: 'text',
154
- text: removed ? `Deleted ${device}` : `Device ${device} did not exist`,
155
- },
156
- ],
157
- isError: false,
158
- };
159
- }
160
-
161
- export const tools = [
162
- {
163
- name: 'thinr_devices',
164
- description:
165
- 'List remote devices with their online/offline status. Pass `query` to filter by device id or name (case-insensitive regex on the server side, so plain substrings, anchors, wildcards and alternations all work). Use this before any other device tool to discover or narrow down ids.',
166
- inputSchema: {
167
- type: 'object',
168
- properties: {
169
- query: {
170
- type: 'string',
171
- description:
172
- 'Optional pattern matched against device id and name. Plain substrings work ("office"); regex metacharacters are also honoured ("^raspberry", "mac|book"). Case-insensitive.',
173
- },
174
- limit: {
175
- type: 'number',
176
- description:
177
- 'Maximum matches to return when `query` is set (default: 20). Ignored when listing without a query.',
178
- },
179
- user: {
180
- type: 'string',
181
- description:
182
- 'API user (for admin impersonation). Omit to use your own account.',
183
- },
184
- },
185
- required: [],
186
- },
187
- handler: toolDevices,
188
- },
189
- {
190
- name: 'thinr_device_info',
191
- description:
192
- 'Get device information: hostname, OS, architecture, filesystem base path, and system resources.',
193
- inputSchema: {
194
- type: 'object',
195
- properties: {
196
- device_id: { type: 'string', description: 'Device ID' },
197
- user: { type: 'string', description: 'API user' },
198
- },
199
- required: ['device_id'],
200
- },
201
- handler: toolDeviceInfo,
202
- },
203
- {
204
- name: 'thinr_device_set_product',
205
- description:
206
- "Assign a device to a product, or unassign it by passing an empty string. Devices inherit the product's API resources and profile configuration (scripts, buckets, etc.) once assigned. Typical flow: create product scripts with thinr_product_script_write, then set a device's product here so it can invoke those scripts.",
207
- inputSchema: {
208
- type: 'object',
209
- properties: {
210
- device_id: { type: 'string', description: 'Device ID' },
211
- product: {
212
- type: 'string',
213
- description: 'Product ID to assign, or empty string "" to unassign.',
214
- },
215
- user: { type: 'string', description: 'API user' },
216
- },
217
- required: ['device_id', 'product'],
218
- },
219
- handler: toolDeviceSetProduct,
220
- },
221
- {
222
- name: 'thinr_device_delete',
223
- description:
224
- 'Delete a device record from the platform. Destructive and irreversible — drops the device, its server-side metadata, and stored properties. The agent on the host (if still running) will be left orphaned and will not auto-recreate the record. Idempotent: returns success when the device was already absent.',
225
- inputSchema: {
226
- type: 'object',
227
- properties: {
228
- device_id: { type: 'string', description: 'Device ID to delete.' },
229
- user: { type: 'string', description: 'API user' },
230
- },
231
- required: ['device_id'],
232
- },
233
- handler: toolDeviceDelete,
234
- },
235
- ];
@@ -1,281 +0,0 @@
1
- // @ts-check
2
- import { readFile as readLocalFile, writeFile as writeLocalFile } from 'fs/promises';
3
- import { resolve as resolvePath, basename } from 'path';
4
- import { getAPI } from './helpers.js';
5
- import { inputError } from '../errors.js';
6
-
7
- async function toolRead(args) {
8
- const { api } = getAPI(args.device_id, args.user);
9
- const content = await api.readFile(args.path);
10
- return {
11
- content: [{ type: 'text', text: content.toString('utf8') }],
12
- isError: false,
13
- };
14
- }
15
-
16
- async function toolWrite(args) {
17
- const { api } = getAPI(args.device_id, args.user);
18
- await api.writeFile(args.path, Buffer.from(args.content, 'utf8'));
19
- return {
20
- content: [{ type: 'text', text: `Written ${args.content.length} bytes to ${args.path}` }],
21
- isError: false,
22
- };
23
- }
24
-
25
- async function toolLs(args) {
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
-
46
- const lines = entries.map((e) => {
47
- const type = e.type === 'directory' ? 'd' : '-';
48
- const mode = e.mode || 'rwxr-xr-x';
49
- const size = (e.size || 0).toString().padStart(8);
50
- return `${type}${mode} ${size} ${e.name}`;
51
- });
52
- return {
53
- content: [{ type: 'text', text: header + (lines.join('\n') || '(empty directory)') }],
54
- isError: false,
55
- };
56
- }
57
-
58
- async function toolMkdir(args) {
59
- const { api } = getAPI(args.device_id, args.user);
60
- await api.mkdir(args.path);
61
- return {
62
- content: [{ type: 'text', text: `Created directory: ${args.path}` }],
63
- isError: false,
64
- };
65
- }
66
-
67
- async function toolRm(args) {
68
- const { api } = getAPI(args.device_id, args.user);
69
- await api.delete(args.path, args.recursive !== false);
70
- return {
71
- content: [{ type: 'text', text: `Deleted: ${args.path}` }],
72
- isError: false,
73
- };
74
- }
75
-
76
- async function toolMv(args) {
77
- const { api } = getAPI(args.device_id, args.user);
78
- await api.move(args.source, args.destination, args.overwrite || false);
79
- return {
80
- content: [{ type: 'text', text: `Moved: ${args.source} → ${args.destination}` }],
81
- isError: false,
82
- };
83
- }
84
-
85
- async function toolPush(args) {
86
- if (typeof args.source !== 'string' || !args.source) {
87
- throw inputError('`source` (local file path) is required');
88
- }
89
- if (typeof args.destination !== 'string' || !args.destination) {
90
- throw inputError('`destination` (remote path on the device) is required');
91
- }
92
- const localPath = resolvePath(args.source);
93
- const remotePath = args.destination.endsWith('/')
94
- ? args.destination + basename(localPath)
95
- : args.destination;
96
- const { api } = getAPI(args.device_id, args.user);
97
- const content = await readLocalFile(localPath);
98
- await api.writeFile(remotePath, content);
99
- return {
100
- content: [
101
- {
102
- type: 'text',
103
- text: `Pushed ${content.byteLength} bytes: ${localPath} → ${remotePath}`,
104
- },
105
- ],
106
- isError: false,
107
- };
108
- }
109
-
110
- async function toolPull(args) {
111
- if (typeof args.source !== 'string' || !args.source) {
112
- throw inputError('`source` (remote path on the device) is required');
113
- }
114
- if (typeof args.destination !== 'string' || !args.destination) {
115
- throw inputError('`destination` (local file path) is required');
116
- }
117
- const localPath = args.destination.endsWith('/')
118
- ? resolvePath(args.destination, basename(args.source))
119
- : resolvePath(args.destination);
120
- const { api } = getAPI(args.device_id, args.user);
121
- const buf = await api.readFile(args.source);
122
- await writeLocalFile(localPath, buf);
123
- return {
124
- content: [
125
- {
126
- type: 'text',
127
- text: `Pulled ${buf.byteLength} bytes: ${args.source} → ${localPath}`,
128
- },
129
- ],
130
- isError: false,
131
- };
132
- }
133
-
134
- export const tools = [
135
- {
136
- name: 'thinr_read',
137
- description: `Read a file from a remote device. Paths are absolute on the device filesystem (e.g., "/etc/hosts", "/var/log/syslog"). Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths like /etc.`,
138
- inputSchema: {
139
- type: 'object',
140
- properties: {
141
- path: { type: 'string', description: 'Absolute file path on the device' },
142
- device_id: { type: 'string', description: 'Device ID' },
143
- user: { type: 'string', description: 'API user' },
144
- },
145
- required: ['path', 'device_id'],
146
- },
147
- handler: toolRead,
148
- },
149
- {
150
- name: 'thinr_write',
151
- description: `Write content to a file on a remote device. Paths are absolute on the device filesystem (e.g., "/etc/hosts", "/var/log/syslog"). Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths like /etc.`,
152
- inputSchema: {
153
- type: 'object',
154
- properties: {
155
- path: { type: 'string', description: 'Absolute file path on the device' },
156
- content: { type: 'string', description: 'File content to write' },
157
- device_id: { type: 'string', description: 'Device ID' },
158
- user: { type: 'string', description: 'API user' },
159
- },
160
- required: ['path', 'content', 'device_id'],
161
- },
162
- handler: toolWrite,
163
- },
164
- {
165
- name: 'thinr_ls',
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.`,
167
- inputSchema: {
168
- type: 'object',
169
- properties: {
170
- path: {
171
- type: 'string',
172
- description:
173
- 'Directory path. Absolute and literal, except "/", which means the agent\'s base directory (default: "/").',
174
- },
175
- include_hidden: {
176
- type: 'boolean',
177
- description: 'Include hidden files (default: false)',
178
- },
179
- device_id: { type: 'string', description: 'Device ID' },
180
- user: { type: 'string', description: 'API user' },
181
- },
182
- required: ['device_id'],
183
- },
184
- handler: toolLs,
185
- },
186
- {
187
- name: 'thinr_mkdir',
188
- description: `Create a directory on a remote device. Paths are absolute on the device filesystem (e.g., "/etc/hosts", "/var/log/syslog"). Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths like /etc.`,
189
- inputSchema: {
190
- type: 'object',
191
- properties: {
192
- path: { type: 'string', description: 'Directory path to create' },
193
- device_id: { type: 'string', description: 'Device ID' },
194
- user: { type: 'string', description: 'API user' },
195
- },
196
- required: ['path', 'device_id'],
197
- },
198
- handler: toolMkdir,
199
- },
200
- {
201
- name: 'thinr_rm',
202
- description: `Delete a file or directory on a remote device. Paths are absolute on the device filesystem (e.g., "/etc/hosts", "/var/log/syslog"). Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths like /etc.`,
203
- inputSchema: {
204
- type: 'object',
205
- properties: {
206
- path: { type: 'string', description: 'Path to delete' },
207
- recursive: {
208
- type: 'boolean',
209
- description: 'Delete directory contents recursively (default: true)',
210
- },
211
- device_id: { type: 'string', description: 'Device ID' },
212
- user: { type: 'string', description: 'API user' },
213
- },
214
- required: ['path', 'device_id'],
215
- },
216
- handler: toolRm,
217
- },
218
- {
219
- name: 'thinr_mv',
220
- description: `Move or rename a file or directory on a remote device. Paths are absolute on the device filesystem (e.g., "/etc/hosts", "/var/log/syslog"). Subject to the OS user the agent runs as; check whoami (or thinr_device_info) before assuming write access to system paths like /etc.`,
221
- inputSchema: {
222
- type: 'object',
223
- properties: {
224
- source: { type: 'string', description: 'Source path' },
225
- destination: { type: 'string', description: 'Destination path' },
226
- overwrite: {
227
- type: 'boolean',
228
- description: 'Overwrite destination if exists (default: false)',
229
- },
230
- device_id: { type: 'string', description: 'Device ID' },
231
- user: { type: 'string', description: 'API user' },
232
- },
233
- required: ['source', 'destination', 'device_id'],
234
- },
235
- handler: toolMv,
236
- },
237
- {
238
- name: 'thinr_push',
239
- description: `Upload a local file to a remote device. \`source\` is a path on the machine running the MCP server; \`destination\` is an absolute path on the device (trailing "/" keeps the source basename). Use this for binary or large payloads that don't fit as inline strings in thinr_write.`,
240
- inputSchema: {
241
- type: 'object',
242
- properties: {
243
- source: {
244
- type: 'string',
245
- description: 'Local file path (absolute or relative to the MCP server CWD).',
246
- },
247
- destination: {
248
- type: 'string',
249
- description:
250
- 'Absolute path on the device. Trailing "/" keeps the source basename.',
251
- },
252
- device_id: { type: 'string', description: 'Device ID' },
253
- user: { type: 'string', description: 'API user' },
254
- },
255
- required: ['source', 'destination', 'device_id'],
256
- },
257
- handler: toolPush,
258
- },
259
- {
260
- name: 'thinr_pull',
261
- description: `Download a remote file from a device to the local disk of the machine running the MCP server. \`source\` is an absolute path on the device; \`destination\` is a local file path (trailing "/" keeps the source basename).`,
262
- inputSchema: {
263
- type: 'object',
264
- properties: {
265
- source: {
266
- type: 'string',
267
- description: 'Absolute file path on the device.',
268
- },
269
- destination: {
270
- type: 'string',
271
- description:
272
- 'Local file path (absolute or relative to the MCP server CWD). Trailing "/" keeps the source basename.',
273
- },
274
- device_id: { type: 'string', description: 'Device ID' },
275
- user: { type: 'string', description: 'API user' },
276
- },
277
- required: ['source', 'destination', 'device_id'],
278
- },
279
- handler: toolPull,
280
- },
281
- ];