@thinremote/thinr-cli 1.0.2 → 1.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.md +362 -87
- package/bin/thinr.js +51 -11
- package/commands/device/_shared.js +48 -0
- package/commands/device/console.js +15 -0
- package/commands/device/exec.js +76 -0
- package/commands/device/index.js +31 -0
- package/commands/device/list.js +43 -0
- package/commands/device/property.js +50 -0
- package/commands/device/proxy.js +42 -0
- package/commands/device/resource.js +51 -0
- package/commands/device/status.js +26 -0
- package/commands/device/update.js +53 -0
- package/commands/logout.js +12 -5
- package/commands/product.js +146 -177
- package/commands/profile.js +105 -0
- package/lib/api.js +54 -31
- package/lib/auth.js +225 -309
- package/lib/config.js +176 -45
- package/lib/console.js +87 -108
- package/lib/device-api.js +257 -0
- package/lib/devices.js +69 -0
- package/lib/errors.js +53 -0
- package/lib/mcp/helpers.js +34 -0
- package/lib/mcp/registry.js +32 -0
- package/lib/mcp/server.js +63 -0
- package/lib/mcp/tools-devices.js +194 -0
- package/lib/mcp/tools-exec.js +213 -0
- package/lib/mcp/tools-filesystem.js +163 -0
- package/lib/mcp/tools-products.js +232 -0
- package/lib/mcp/tools-profiles.js +30 -0
- package/lib/mcp/tools-properties.js +79 -0
- package/lib/mcp/tools-resources.js +76 -0
- package/lib/mcp/tools-scripts.js +172 -0
- package/lib/monitoring.js +72 -0
- package/lib/output.js +114 -0
- package/lib/product.js +373 -0
- package/lib/property.js +41 -57
- package/lib/proxy.js +58 -90
- package/lib/resource.js +106 -72
- package/lib/status.js +7 -33
- package/lib/storage.js +128 -0
- package/package.json +68 -37
- package/.github/workflows/publish.yml +0 -79
- package/commands/device.js +0 -268
- package/lib/device.js +0 -76
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import api from './api.js';
|
|
2
|
+
import { requireConfig } from './config.js';
|
|
3
|
+
import WebSocket from 'ws';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a device API client for filesystem and command operations
|
|
8
|
+
* @param {string} deviceId - The device ID
|
|
9
|
+
* @param {Object} [options] - Options
|
|
10
|
+
* @param {string} [options.user] - Override API user (admin impersonation)
|
|
11
|
+
* @returns {Object} API client with fs and exec methods
|
|
12
|
+
*/
|
|
13
|
+
export function createDeviceAPI(deviceId, options = {}) {
|
|
14
|
+
const config = requireConfig();
|
|
15
|
+
const apiUser = options.user || config.username;
|
|
16
|
+
const resourcePath = `/v3/users/${apiUser}/devices/${deviceId}/resources`;
|
|
17
|
+
const filesPath = `/v3/users/${apiUser}/devices/${deviceId}/files`;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Call a device resource (for cmd, etc.)
|
|
21
|
+
*/
|
|
22
|
+
async function callResource(resource, payload = null, opts = {}) {
|
|
23
|
+
const url = `${resourcePath}/${resource}`;
|
|
24
|
+
const response = await api.post(url, payload, {
|
|
25
|
+
responseType: opts.responseType || 'json',
|
|
26
|
+
params: opts.params || {},
|
|
27
|
+
headers: opts.headers || {},
|
|
28
|
+
timeout: opts.timeout || 30000,
|
|
29
|
+
});
|
|
30
|
+
return response;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
/**
|
|
35
|
+
* List directory contents
|
|
36
|
+
* GET /v3/users/{user}/devices/{device}/files/{path}/
|
|
37
|
+
*/
|
|
38
|
+
async listDir(dirPath, includeHidden = false) {
|
|
39
|
+
const normalized = dirPath.endsWith('/') ? dirPath : dirPath + '/';
|
|
40
|
+
const params = {};
|
|
41
|
+
if (includeHidden) params.hidden = true;
|
|
42
|
+
const response = await api.get(`${filesPath}${normalized}`, { params });
|
|
43
|
+
return response.data;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read file contents
|
|
48
|
+
* GET /v3/users/{user}/devices/{device}/files/{path}
|
|
49
|
+
*/
|
|
50
|
+
async readFile(filePath) {
|
|
51
|
+
const response = await api.get(`${filesPath}${filePath}`, {
|
|
52
|
+
responseType: 'arraybuffer',
|
|
53
|
+
});
|
|
54
|
+
return Buffer.from(response.data);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Write file contents
|
|
59
|
+
* PUT /v3/users/{user}/devices/{device}/files/{path}
|
|
60
|
+
*/
|
|
61
|
+
async writeFile(filePath, content, overwrite = true) {
|
|
62
|
+
const params = {};
|
|
63
|
+
if (!overwrite) params.overwrite = false;
|
|
64
|
+
const response = await api.put(`${filesPath}${filePath}`, content, {
|
|
65
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
66
|
+
params,
|
|
67
|
+
});
|
|
68
|
+
return response.data;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get file/directory info
|
|
73
|
+
* GET /v3/users/{user}/devices/{device}/files/{path}?info=true
|
|
74
|
+
*/
|
|
75
|
+
async info(filePath) {
|
|
76
|
+
const response = await api.get(`${filesPath}${filePath}`, {
|
|
77
|
+
params: { info: true },
|
|
78
|
+
});
|
|
79
|
+
return response.data;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Delete file or directory
|
|
84
|
+
* DELETE /v3/users/{user}/devices/{device}/files/{path}
|
|
85
|
+
*/
|
|
86
|
+
async delete(filePath, recursive = true) {
|
|
87
|
+
const params = {};
|
|
88
|
+
if (recursive) params.recursive = true;
|
|
89
|
+
const response = await api.delete(`${filesPath}${filePath}`, { params });
|
|
90
|
+
return response.data;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Move/rename file or directory
|
|
95
|
+
* PATCH /v3/users/{user}/devices/{device}/files/{sourcePath}
|
|
96
|
+
*/
|
|
97
|
+
async move(source, destination, overwrite = false) {
|
|
98
|
+
const response = await api.patch(`${filesPath}${source}`, {
|
|
99
|
+
path: destination,
|
|
100
|
+
overwrite,
|
|
101
|
+
});
|
|
102
|
+
return response.data;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create directory
|
|
107
|
+
* PUT /v3/users/{user}/devices/{device}/files/{path}/
|
|
108
|
+
*/
|
|
109
|
+
async mkdir(dirPath) {
|
|
110
|
+
const normalized = dirPath.endsWith('/') ? dirPath : dirPath + '/';
|
|
111
|
+
const response = await api.put(`${filesPath}${normalized}`, null, {
|
|
112
|
+
headers: { 'Content-Length': '0' },
|
|
113
|
+
});
|
|
114
|
+
return response.data;
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read a device resource property (GET)
|
|
119
|
+
* GET /v3/users/{user}/devices/{device}/resources/{resource}
|
|
120
|
+
*/
|
|
121
|
+
async getResource(resource) {
|
|
122
|
+
const url = `${resourcePath}/${resource}`;
|
|
123
|
+
const response = await api.get(url, { timeout: 15000 });
|
|
124
|
+
return response.data;
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Call a device resource action (POST)
|
|
129
|
+
* POST /v3/users/{user}/devices/{device}/resources/{resource}
|
|
130
|
+
*/
|
|
131
|
+
async callResource(resource, payload = null, { timeout = 30000 } = {}) {
|
|
132
|
+
const url = `${resourcePath}/${resource}`;
|
|
133
|
+
const response = await api.post(url, payload, { timeout });
|
|
134
|
+
return response.data;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Execute a command on the device
|
|
139
|
+
* POST /v3/users/{user}/devices/{device}/resources/cmd
|
|
140
|
+
*/
|
|
141
|
+
async exec(cmd, timeout = 30) {
|
|
142
|
+
const response = await callResource(
|
|
143
|
+
'cmd',
|
|
144
|
+
{
|
|
145
|
+
cmd,
|
|
146
|
+
timeout,
|
|
147
|
+
mode: 'api',
|
|
148
|
+
},
|
|
149
|
+
{ timeout: (timeout + 5) * 1000 },
|
|
150
|
+
);
|
|
151
|
+
return response.data;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Execute a command on the device using the WebSocket stream. Incoming
|
|
156
|
+
* frames are dispatched to onStdout/onStderr as they arrive. Resolves
|
|
157
|
+
* with { exitCode, timedOut } when the remote command completes or the
|
|
158
|
+
* connection closes. Returns a `cancel()` function via the optional
|
|
159
|
+
* onCancel hook so callers (e.g. SIGINT handlers) can tear it down.
|
|
160
|
+
*
|
|
161
|
+
* Preferred path for the CLI: the server detects WS close and stops
|
|
162
|
+
* the device-side stream automatically.
|
|
163
|
+
*/
|
|
164
|
+
execStream(cmd, { timeout = 0, onStdout, onStderr, onCancel, stdin } = {}) {
|
|
165
|
+
const sessionId = randomUUID();
|
|
166
|
+
const resource = `$cmd/${sessionId}`;
|
|
167
|
+
const params = new URLSearchParams({ cmd });
|
|
168
|
+
if (timeout > 0) params.set('timeout', String(timeout));
|
|
169
|
+
const isLocal = config.server === 'localhost' || config.server === '127.0.0.1';
|
|
170
|
+
const insecure = isLocal || process.env.THINR_INSECURE === '1';
|
|
171
|
+
const wsUrl = `wss://${config.server}/v3/users/${apiUser}/devices/${deviceId}/resources/${resource}?${params}`;
|
|
172
|
+
const ws = new WebSocket(wsUrl, {
|
|
173
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
174
|
+
rejectUnauthorized: !insecure,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
let exitCode = null;
|
|
178
|
+
let timedOut = false;
|
|
179
|
+
let cancelled = false;
|
|
180
|
+
|
|
181
|
+
const cancel = () => {
|
|
182
|
+
cancelled = true;
|
|
183
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
184
|
+
ws.close();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
onCancel?.(cancel);
|
|
188
|
+
|
|
189
|
+
const decodeBytes = (field) => {
|
|
190
|
+
if (typeof field === 'string') return field;
|
|
191
|
+
if (field && Array.isArray(field.bytes)) {
|
|
192
|
+
return Buffer.from(field.bytes).toString('utf8');
|
|
193
|
+
}
|
|
194
|
+
return '';
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
let resolved = false;
|
|
199
|
+
const done = () => {
|
|
200
|
+
if (resolved) return;
|
|
201
|
+
resolved = true;
|
|
202
|
+
resolve({ exitCode, timedOut, cancelled });
|
|
203
|
+
// Tear down the socket without blocking on the server's
|
|
204
|
+
// close handshake.
|
|
205
|
+
if (
|
|
206
|
+
ws.readyState === WebSocket.OPEN ||
|
|
207
|
+
ws.readyState === WebSocket.CONNECTING
|
|
208
|
+
) {
|
|
209
|
+
ws.terminate();
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
ws.on('open', () => {
|
|
214
|
+
// If the caller supplied a stdin stream, forward each
|
|
215
|
+
// chunk to the device as a stream frame. Payload is sent
|
|
216
|
+
// as a UTF-8 string under the standard {resource, payload}
|
|
217
|
+
// envelope that the server's stream handler expects.
|
|
218
|
+
if (stdin && typeof stdin.on === 'function') {
|
|
219
|
+
stdin.on('data', (chunk) => {
|
|
220
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
221
|
+
const text = Buffer.isBuffer(chunk)
|
|
222
|
+
? chunk.toString('utf8')
|
|
223
|
+
: String(chunk);
|
|
224
|
+
ws.send(JSON.stringify({ resource, payload: text }));
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
ws.on('error', (err) => {
|
|
229
|
+
if (cancelled) return;
|
|
230
|
+
reject(err);
|
|
231
|
+
});
|
|
232
|
+
ws.on('message', (raw) => {
|
|
233
|
+
let evt;
|
|
234
|
+
try {
|
|
235
|
+
evt = JSON.parse(raw.toString('utf8'));
|
|
236
|
+
} catch {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (evt.signal && evt.signal !== 'data') return;
|
|
240
|
+
const p = evt.payload;
|
|
241
|
+
if (!p || typeof p !== 'object') return;
|
|
242
|
+
if (p.out !== undefined) onStdout?.(decodeBytes(p.out));
|
|
243
|
+
else if (p.err !== undefined) onStderr?.(decodeBytes(p.err));
|
|
244
|
+
else if (typeof p.exit === 'number') {
|
|
245
|
+
exitCode = p.exit;
|
|
246
|
+
if (p.timeout === true) timedOut = true;
|
|
247
|
+
// We have the full result — resolve immediately and
|
|
248
|
+
// tear down the WS without waiting for the server's
|
|
249
|
+
// stop cascade, which can add several seconds.
|
|
250
|
+
done();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
ws.on('close', () => done());
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
package/lib/devices.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { requireConfig } from './config.js';
|
|
2
|
+
import api from './api.js';
|
|
3
|
+
import { apiError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get devices belonging to a product
|
|
7
|
+
* @param {Object} filter - Filters to query devices
|
|
8
|
+
* @return {Promise<Array>} List of devices
|
|
9
|
+
*/
|
|
10
|
+
export async function getDevices(filter, user = null) {
|
|
11
|
+
const config = requireConfig();
|
|
12
|
+
const apiUser = user || config.username;
|
|
13
|
+
let devices = [];
|
|
14
|
+
|
|
15
|
+
let queryParametersString = '';
|
|
16
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
17
|
+
if (value) queryParametersString += key + '=' + value + '&';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const count = 50;
|
|
22
|
+
const url = `/v1/users/${apiUser}/devices?count=${count}&${queryParametersString}`;
|
|
23
|
+
let index = 0;
|
|
24
|
+
let res_length = 0;
|
|
25
|
+
|
|
26
|
+
do {
|
|
27
|
+
const response = await api.get(`${url}index=${index}`);
|
|
28
|
+
res_length = response.data.length;
|
|
29
|
+
index += res_length;
|
|
30
|
+
devices = devices.concat(response.data);
|
|
31
|
+
} while (res_length === count);
|
|
32
|
+
|
|
33
|
+
return devices;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const product = filter && filter.productId;
|
|
36
|
+
const notFound = product ? `Product not found: ${product}` : 'Devices not found';
|
|
37
|
+
throw apiError(error, { notFound });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fetch a single device record (metadata only — assigned product,
|
|
43
|
+
* description, asset group, connection state, …). Use this when you
|
|
44
|
+
* need the side-channel info that doesn't come from the device's own
|
|
45
|
+
* agent (system_info, monitoring, …) but from the server's record.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} deviceId
|
|
48
|
+
* @param {string} [user] Admin impersonation; defaults to the active profile.
|
|
49
|
+
* @returns {Promise<Object>} Raw API record.
|
|
50
|
+
*/
|
|
51
|
+
export async function getDevice(deviceId, user = null) {
|
|
52
|
+
const config = requireConfig();
|
|
53
|
+
const apiUser = user || config.username;
|
|
54
|
+
try {
|
|
55
|
+
const response = await api.get(`/v1/users/${apiUser}/devices/${deviceId}`);
|
|
56
|
+
return response.data || {};
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw apiError(error, { notFound: `Device not found: ${deviceId}` });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Filter active devices from a list of devices
|
|
64
|
+
* @param {Array} devices - List of devices to filter
|
|
65
|
+
* @return {Array} List of active devices
|
|
66
|
+
*/
|
|
67
|
+
export function filterActiveDevices(devices) {
|
|
68
|
+
return devices.filter((device) => device.connection && device.connection.active === true);
|
|
69
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize an axios / fetch-style error into a single Error with a stable
|
|
3
|
+
* `.code` and `.status`. Replaces the repeated if-response/if-request
|
|
4
|
+
* blocks in status.js / property.js / resource.js / device.js.
|
|
5
|
+
*
|
|
6
|
+
* Pass `messages.notFound` to override the 404 message with something
|
|
7
|
+
* that names the missing entity (e.g. "Device not found: abc").
|
|
8
|
+
*/
|
|
9
|
+
export function apiError(error, messages = {}) {
|
|
10
|
+
if (error && error.response) {
|
|
11
|
+
const status = error.response.status;
|
|
12
|
+
if (status === 404) {
|
|
13
|
+
const err = new Error(messages.notFound || 'Not found');
|
|
14
|
+
err.code = 'not_found';
|
|
15
|
+
err.status = 404;
|
|
16
|
+
return err;
|
|
17
|
+
}
|
|
18
|
+
if (status === 401 || status === 403) {
|
|
19
|
+
const err = new Error(
|
|
20
|
+
messages.unauthorized ||
|
|
21
|
+
'Unauthorized. Your token may have expired. Please reconfigure.',
|
|
22
|
+
);
|
|
23
|
+
err.code = 'unauthorized';
|
|
24
|
+
err.status = status;
|
|
25
|
+
return err;
|
|
26
|
+
}
|
|
27
|
+
const err = new Error(`Server error: ${status} ${error.response.statusText || ''}`.trim());
|
|
28
|
+
err.code = 'server_error';
|
|
29
|
+
err.status = status;
|
|
30
|
+
return err;
|
|
31
|
+
}
|
|
32
|
+
if (error && error.request) {
|
|
33
|
+
const err = new Error('No response from server. Please check your connection.');
|
|
34
|
+
err.code = 'network_error';
|
|
35
|
+
return err;
|
|
36
|
+
}
|
|
37
|
+
const err = new Error(error?.message || String(error));
|
|
38
|
+
err.code = 'error';
|
|
39
|
+
return err;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pre-tagged error for invalid / missing user input (CLI option,
|
|
44
|
+
* MCP tool argument, …). Picked up by `classifyError` so it surfaces
|
|
45
|
+
* as `code: 'input_error'` in both the CLI JSON envelope and the
|
|
46
|
+
* MCP error metadata, matching the same code the CLI's `printErr`
|
|
47
|
+
* uses for argument validation failures.
|
|
48
|
+
*/
|
|
49
|
+
export function inputError(message) {
|
|
50
|
+
const err = new Error(message);
|
|
51
|
+
err.code = 'input_error';
|
|
52
|
+
return err;
|
|
53
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { createDeviceAPI } from '../device-api.js';
|
|
3
|
+
import { getActiveProfile, getProfile, setActiveProfile } from '../config.js';
|
|
4
|
+
import { setBaseURL } from '../api.js';
|
|
5
|
+
import { inputError } from '../errors.js';
|
|
6
|
+
|
|
7
|
+
/** Build a device-scoped API client. Tools must always pass `device`
|
|
8
|
+
* explicitly — the server has no per-session default. */
|
|
9
|
+
export function getAPI(device, user) {
|
|
10
|
+
if (!device)
|
|
11
|
+
throw inputError('device is required. Use thinr_devices to list available devices.');
|
|
12
|
+
return { api: createDeviceAPI(device, { user: user || undefined }), device };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Run `fn` with the active profile temporarily switched to `profile`
|
|
16
|
+
* (when supplied) so callers can target any configured environment per
|
|
17
|
+
* tool call without persisting the change. */
|
|
18
|
+
export async function withProfile(profile, fn) {
|
|
19
|
+
if (!profile) return fn();
|
|
20
|
+
const previous = getActiveProfile();
|
|
21
|
+
const target = getProfile(profile);
|
|
22
|
+
if (!target) {
|
|
23
|
+
throw inputError(`Unknown profile: ${profile}. Use thinr_profiles to list available ones.`);
|
|
24
|
+
}
|
|
25
|
+
setActiveProfile(profile);
|
|
26
|
+
if (target.server) setBaseURL(`https://${target.server}`);
|
|
27
|
+
try {
|
|
28
|
+
return await fn();
|
|
29
|
+
} finally {
|
|
30
|
+
setActiveProfile(previous);
|
|
31
|
+
const restore = previous ? getProfile(previous) : null;
|
|
32
|
+
if (restore?.server) setBaseURL(`https://${restore.server}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { tools as devicesTools } from './tools-devices.js';
|
|
3
|
+
import { tools as filesystemTools } from './tools-filesystem.js';
|
|
4
|
+
import { tools as execTools } from './tools-exec.js';
|
|
5
|
+
import { tools as resourcesTools } from './tools-resources.js';
|
|
6
|
+
import { tools as propertiesTools } from './tools-properties.js';
|
|
7
|
+
import { tools as scriptsTools } from './tools-scripts.js';
|
|
8
|
+
import { tools as productsTools } from './tools-products.js';
|
|
9
|
+
import { tools as profilesTools } from './tools-profiles.js';
|
|
10
|
+
|
|
11
|
+
export const tools = [
|
|
12
|
+
...devicesTools,
|
|
13
|
+
...filesystemTools,
|
|
14
|
+
...execTools,
|
|
15
|
+
...resourcesTools,
|
|
16
|
+
...propertiesTools,
|
|
17
|
+
...scriptsTools,
|
|
18
|
+
...productsTools,
|
|
19
|
+
...profilesTools,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Inject the optional `profile` parameter on every tool so callers can
|
|
23
|
+
// target any configured environment per-call without changing the saved
|
|
24
|
+
// default. `thinr_profiles` is the lookup itself, so it's exempt.
|
|
25
|
+
for (const t of tools) {
|
|
26
|
+
if (t.name === 'thinr_profiles') continue;
|
|
27
|
+
t.inputSchema.properties = t.inputSchema.properties || {};
|
|
28
|
+
t.inputSchema.properties.profile = {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'CLI profile to use for this call (see thinr_profiles). Omit for the default.',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { readConfig } from '../config.js';
|
|
6
|
+
import { setBaseURL } from '../api.js';
|
|
7
|
+
import { classifyError } from '../output.js';
|
|
8
|
+
import { tools } from './registry.js';
|
|
9
|
+
import { withProfile } from './helpers.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Start a generic MCP server for ThinRemote.
|
|
13
|
+
* All tools accept device (when relevant) plus optional user/profile parameters.
|
|
14
|
+
*/
|
|
15
|
+
export async function startMCPServer() {
|
|
16
|
+
const config = readConfig();
|
|
17
|
+
if (config.server) {
|
|
18
|
+
setBaseURL(`https://${config.server}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const server = new Server(
|
|
22
|
+
{
|
|
23
|
+
name: 'thinr',
|
|
24
|
+
version: '1.0.0',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
capabilities: { tools: {} },
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
32
|
+
tools: tools.map(({ handler: _handler, ...t }) => t),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
36
|
+
const name = request.params.name;
|
|
37
|
+
const args = request.params.arguments || {};
|
|
38
|
+
const tool = tools.find((t) => t.name === name);
|
|
39
|
+
if (!tool) {
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
42
|
+
isError: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return await withProfile(args.profile, () => tool.handler(args));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Surface the same {message, code} pair the CLI emits in JSON
|
|
49
|
+
// mode so MCP clients can pattern-match on `[code]` instead of
|
|
50
|
+
// doing fuzzy matches on the human message.
|
|
51
|
+
const { message, code } = classifyError(error);
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: 'text', text: `Error [${code}]: ${message}` }],
|
|
54
|
+
isError: true,
|
|
55
|
+
_meta: { code, message },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const transport = new StdioServerTransport();
|
|
61
|
+
await server.connect(transport);
|
|
62
|
+
console.error('[thinr-mcp] Server started');
|
|
63
|
+
}
|