@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
package/lib/config.js DELETED
@@ -1,230 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
-
5
- /**
6
- * Profile-based configuration store.
7
- *
8
- * On disk the config file looks like:
9
- * {
10
- * "default": "<profile-name>",
11
- * "profiles": {
12
- * "<profile-name>": { server, username, token, refresh_token, ... }
13
- * }
14
- * }
15
- *
16
- * Profile names default to the server hostname so login against a new
17
- * server automatically creates an isolated profile.
18
- *
19
- * Legacy single-profile files (top-level server/username/token) are
20
- * detected on read and migrated to the new layout transparently. The
21
- * migration uses the legacy `server` field as the profile name.
22
- *
23
- * The "active" profile is the one all helpers operate on. It is picked
24
- * (in order): explicit override via setActiveProfile(), the THINR_PROFILE
25
- * environment variable, the file's `default` field.
26
- */
27
-
28
- let activeProfileOverride = null;
29
-
30
- /**
31
- * Resolve the on-disk config path, creating the parent directory on
32
- * first access. Internal — every consumer goes through readStore /
33
- * writeStore so they never have to know the location.
34
- */
35
- function getConfigPath() {
36
- const homeDir = os.homedir();
37
- const configDir = path.join(homeDir, '.config', 'thinr-cli');
38
-
39
- if (!fs.existsSync(configDir)) {
40
- fs.mkdirSync(configDir, { recursive: true });
41
- }
42
-
43
- return path.join(configDir, 'config.json');
44
- }
45
-
46
- function emptyStore() {
47
- return { default: null, profiles: {} };
48
- }
49
-
50
- function readStore() {
51
- const configPath = getConfigPath();
52
- if (!fs.existsSync(configPath)) return emptyStore();
53
- let raw;
54
- try {
55
- raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
56
- } catch {
57
- return emptyStore();
58
- }
59
-
60
- // Legacy format: top-level {server, username, token, ...}. Migrate to
61
- // a profile keyed by the server hostname.
62
- if (raw && typeof raw === 'object' && !raw.profiles && raw.server) {
63
- const name = String(raw.server);
64
- return { default: name, profiles: { [name]: { ...raw } } };
65
- }
66
-
67
- if (!raw || typeof raw !== 'object' || !raw.profiles) return emptyStore();
68
- return raw;
69
- }
70
-
71
- function writeStore(store) {
72
- const configPath = getConfigPath();
73
- fs.writeFileSync(configPath, JSON.stringify(store, null, 2), 'utf8');
74
- }
75
-
76
- /**
77
- * Override the active profile for the current process. Pass null to fall
78
- * back to env var / file default. Used by the CLI entry point when the
79
- * user passes --profile.
80
- */
81
- export function setActiveProfile(name) {
82
- activeProfileOverride = name || null;
83
- }
84
-
85
- /**
86
- * Resolve the active profile name, or null if no profile exists.
87
- */
88
- export function getActiveProfile() {
89
- if (activeProfileOverride) return activeProfileOverride;
90
- if (process.env.THINR_PROFILE) return process.env.THINR_PROFILE;
91
- const store = readStore();
92
- if (store.default && store.profiles[store.default]) return store.default;
93
- // Fall back to the only profile if there's exactly one.
94
- const names = Object.keys(store.profiles);
95
- return names.length === 1 ? names[0] : null;
96
- }
97
-
98
- /**
99
- * List all profile names.
100
- */
101
- export function listProfiles() {
102
- return Object.keys(readStore().profiles);
103
- }
104
-
105
- /**
106
- * Get a specific profile by name (or undefined if missing).
107
- */
108
- export function getProfile(name) {
109
- const store = readStore();
110
- return store.profiles[name];
111
- }
112
-
113
- /**
114
- * Set the persisted default profile.
115
- */
116
- export function setDefaultProfile(name) {
117
- const store = readStore();
118
- if (!store.profiles[name]) {
119
- throw new Error(`Profile not found: ${name}`);
120
- }
121
- store.default = name;
122
- writeStore(store);
123
- }
124
-
125
- /**
126
- * Remove a profile by name. Returns true if removed.
127
- */
128
- export function deleteProfile(name) {
129
- const store = readStore();
130
- if (!store.profiles[name]) return false;
131
- delete store.profiles[name];
132
- if (store.default === name) {
133
- const remaining = Object.keys(store.profiles);
134
- store.default = remaining[0] || null;
135
- }
136
- writeStore(store);
137
- return true;
138
- }
139
-
140
- /**
141
- * Check if any profile is configured.
142
- */
143
- export function configExists() {
144
- const store = readStore();
145
- if (!Object.keys(store.profiles).length) return false;
146
- return getActiveProfile() != null;
147
- }
148
-
149
- /**
150
- * Read the active profile's data.
151
- * @returns {Object} Active profile fields (server, username, token, ...) or {}
152
- */
153
- export function readConfig() {
154
- const store = readStore();
155
- const name = getActiveProfile();
156
- if (!name) return {};
157
- return store.profiles[name] || {};
158
- }
159
-
160
- /**
161
- * Read the active profile and assert that it has the fields every API
162
- * call needs (server, username, token). Throws a tagged error so the
163
- * MCP and CLI surfaces both classify the failure as `not_configured`
164
- * via classifyError. Replaces the same boilerplate that used to live
165
- * at the top of every helper in lib/.
166
- */
167
- /**
168
- * The account an API call is addressed to: an explicit one when acting on
169
- * behalf of another account, the active profile's own otherwise. Single
170
- * source of truth — every path in lib/paths.js goes through it.
171
- */
172
- export function resolveUser(user) {
173
- return user || requireConfig().username;
174
- }
175
-
176
- export function requireConfig() {
177
- const config = readConfig();
178
- if (!config.token || !config.server || !config.username) {
179
- const err = new Error('Not configured. Run thinr without parameters to set up.');
180
- err.code = 'not_configured';
181
- throw err;
182
- }
183
- return config;
184
- }
185
-
186
- /**
187
- * Write a fresh profile from the supplied data. The profile name is
188
- * picked (in order): an explicit override set via setActiveProfile()
189
- * (used by `profile add` to scope the write to a named profile), the
190
- * supplied `server`, or the currently active profile name. Becomes the
191
- * default if there isn't one yet.
192
- */
193
- export function writeConfig(config) {
194
- const store = readStore();
195
- const name = activeProfileOverride || (config && config.server) || getActiveProfile();
196
- if (!name) {
197
- throw new Error('Cannot write config without a server / profile name');
198
- }
199
- store.profiles[name] = { ...config };
200
- if (!store.default) store.default = name;
201
- writeStore(store);
202
- }
203
-
204
- /**
205
- * Merge fields into the active profile.
206
- */
207
- export function mergeConfig(newConfig) {
208
- const store = readStore();
209
- const name = getActiveProfile();
210
- if (!name) {
211
- // No active profile yet: fall back to writeConfig semantics.
212
- return writeConfig(newConfig);
213
- }
214
- store.profiles[name] = { ...store.profiles[name], ...newConfig };
215
- writeStore(store);
216
- }
217
-
218
- /**
219
- * Delete the entire config (all profiles).
220
- */
221
- export function deleteConfig() {
222
- const configPath = getConfigPath();
223
- if (!fs.existsSync(configPath)) return true;
224
- try {
225
- fs.unlinkSync(configPath);
226
- return true;
227
- } catch {
228
- return false;
229
- }
230
- }
package/lib/devices.js DELETED
@@ -1,60 +0,0 @@
1
- import api from './api.js';
2
- import { apiError } from './errors.js';
3
- import { v1 } from './paths.js';
4
-
5
- export async function getDevices(filter, user = null) {
6
- let devices = [];
7
-
8
- let queryParametersString = '';
9
- for (const [key, value] of Object.entries(filter)) {
10
- if (value) queryParametersString += key + '=' + value + '&';
11
- }
12
-
13
- try {
14
- const count = 50;
15
- const url = `${v1(user)}/devices?count=${count}&${queryParametersString}`;
16
- let index = 0;
17
- let res_length = 0;
18
-
19
- do {
20
- const response = await api.get(`${url}index=${index}`);
21
- res_length = response.data.length;
22
- index += res_length;
23
- devices = devices.concat(response.data);
24
- } while (res_length === count);
25
-
26
- return devices;
27
- } catch (error) {
28
- const product = filter && filter.product;
29
- const notFound = product ? `Product not found: ${product}` : 'Devices not found';
30
- throw apiError(error, { notFound });
31
- }
32
- }
33
-
34
- // Server-side metadata (assigned product, description, asset group,
35
- // connection state…). For device-side data — system_info, monitoring —
36
- // hit the agent resources directly instead.
37
- export async function getDevice(deviceId, user = null) {
38
- try {
39
- const response = await api.get(`${v1(user)}/devices/${deviceId}`);
40
- return response.data || {};
41
- } catch (error) {
42
- throw apiError(error, { notFound: `Device not found: ${deviceId}` });
43
- }
44
- }
45
-
46
- // Idempotent: returns true if the device record was removed, false if it
47
- // was already absent. Other failures (auth, network) propagate as apiError.
48
- export async function deleteDevice(deviceId, user = null) {
49
- try {
50
- await api.delete(`${v1(user)}/devices/${deviceId}`);
51
- return true;
52
- } catch (error) {
53
- if (error.response?.status === 404) return false;
54
- throw apiError(error);
55
- }
56
- }
57
-
58
- export function filterActiveDevices(devices) {
59
- return devices.filter((device) => device.connection && device.connection.active === true);
60
- }
package/lib/errors.js DELETED
@@ -1,65 +0,0 @@
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
- // Errors normalized upstream (the tagged 'unauthorized' from a failed
11
- // token refresh, inputError, ...) carry a stable string code and no
12
- // axios envelope: pass them through so the code survives.
13
- if (
14
- error &&
15
- typeof error.code === 'string' &&
16
- !error.isAxiosError &&
17
- !error.response &&
18
- !error.request
19
- ) {
20
- return error;
21
- }
22
- if (error && error.response) {
23
- const status = error.response.status;
24
- if (status === 404) {
25
- const err = new Error(messages.notFound || 'Not found');
26
- err.code = 'not_found';
27
- err.status = 404;
28
- return err;
29
- }
30
- if (status === 401 || status === 403) {
31
- const err = new Error(
32
- messages.unauthorized ||
33
- 'Unauthorized. Your token may have expired. Please reconfigure.',
34
- );
35
- err.code = 'unauthorized';
36
- err.status = status;
37
- return err;
38
- }
39
- const err = new Error(`Server error: ${status} ${error.response.statusText || ''}`.trim());
40
- err.code = 'server_error';
41
- err.status = status;
42
- return err;
43
- }
44
- if (error && error.request) {
45
- const err = new Error('No response from server. Please check your connection.');
46
- err.code = 'network_error';
47
- return err;
48
- }
49
- const err = new Error(error?.message || String(error));
50
- err.code = 'error';
51
- return err;
52
- }
53
-
54
- /**
55
- * Pre-tagged error for invalid / missing user input (CLI option,
56
- * MCP tool argument, …). Picked up by `classifyError` so it surfaces
57
- * as `code: 'input_error'` in both the CLI JSON envelope and the
58
- * MCP error metadata, matching the same code the CLI's `printErr`
59
- * uses for argument validation failures.
60
- */
61
- export function inputError(message) {
62
- const err = new Error(message);
63
- err.code = 'input_error';
64
- return err;
65
- }
package/lib/format.js DELETED
@@ -1,114 +0,0 @@
1
- // @ts-check
2
- import chalk from 'chalk';
3
-
4
- // ─────────────────────────────────────────────────────────────────────
5
- // Theme — semantic wrappers over chalk so every CLI surface speaks
6
- // the same visual language. New colours / a `--no-color` switch / a
7
- // dark-on-light adjustment only have to be touched here.
8
- // ─────────────────────────────────────────────────────────────────────
9
-
10
- /** Title / header text. */
11
- export const label = (s) => chalk.bold(s);
12
- /** Secondary / disabled / "—" text. */
13
- export const muted = (s) => chalk.dim(s);
14
- /** Hints, trailing metadata, footers, timestamps. */
15
- export const hint = (s) => chalk.gray(s);
16
- /** Positive states (online, ok, success). */
17
- export const success = (s) => chalk.green(s);
18
- /** Negative states (offline, failed, errored). */
19
- export const error = (s) => chalk.red(s);
20
- /** Caution states (timeout, at-risk thresholds). */
21
- export const warning = (s) => chalk.yellow(s);
22
- /** Decorative accents (separators, prompts). */
23
- export const accent = (s) => chalk.cyan(s);
24
- /** Neutral-but-noteworthy identifiers (device ids, resource names). */
25
- export const info = (s) => chalk.blue(s);
26
-
27
- /**
28
- * Format a duration in seconds as a human-readable string: `309d16h`,
29
- * `42d15h`, `17m`, `42s`, etc. Returns `—` for invalid/missing values.
30
- *
31
- * @param {number | null | undefined} seconds
32
- * @returns {string}
33
- */
34
- export function formatUptime(seconds) {
35
- if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '—';
36
- const d = Math.floor(seconds / 86400);
37
- const h = Math.floor((seconds % 86400) / 3600);
38
- const m = Math.floor((seconds % 3600) / 60);
39
- if (d > 0) return `${d}d${h}h`;
40
- if (h > 0) return `${h}h${m}m`;
41
- if (m > 0) return `${m}m`;
42
- return `${Math.floor(seconds)}s`;
43
- }
44
-
45
- /**
46
- * Format seconds elapsed as a "time ago" label: `5s ago`, `3m ago`,
47
- * `2h ago`, `4d ago`. Returns `—` for invalid/missing values.
48
- *
49
- * @param {number | null | undefined} seconds
50
- * @returns {string}
51
- */
52
- export function formatAgo(seconds) {
53
- if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '—';
54
- if (seconds < 60) return `${Math.floor(seconds)}s ago`;
55
- if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
56
- if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
57
- return `${Math.floor(seconds / 86400)}d ago`;
58
- }
59
-
60
- /**
61
- * Render a percentage value coloured by threshold: green below 70 %,
62
- * yellow below 90 %, red at or above 90 %. Returns a dim `—` when the
63
- * value is missing. Formatted with no decimal places so columns in
64
- * cli-table3 stay aligned.
65
- *
66
- * @param {number | null | undefined} v
67
- * @returns {string}
68
- */
69
- export function colorPct(v) {
70
- if (v == null || !Number.isFinite(v)) return muted('—');
71
- const s = `${v.toFixed(0)}%`;
72
- if (v >= 90) return error(s);
73
- if (v >= 70) return warning(s);
74
- return success(s);
75
- }
76
-
77
- /**
78
- * Format a duration in seconds as a short ETA label: `12s`, `3m15s`,
79
- * `2h30m`. Returns `—` for non-finite / missing values.
80
- *
81
- * @param {number | null | undefined} seconds
82
- * @returns {string}
83
- */
84
- export function formatETA(seconds) {
85
- if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '—';
86
- if (seconds < 60) return `${Math.max(1, Math.ceil(seconds))}s`;
87
- if (seconds < 3600) {
88
- const m = Math.floor(seconds / 60);
89
- const s = Math.ceil(seconds % 60);
90
- return s > 0 ? `${m}m${s}s` : `${m}m`;
91
- }
92
- const h = Math.floor(seconds / 3600);
93
- const m = Math.ceil((seconds % 3600) / 60);
94
- return m > 0 ? `${h}h${m}m` : `${h}h`;
95
- }
96
-
97
- /**
98
- * Format a byte count as a human-readable string picking the largest
99
- * unit where the value is ≥ 1. `2 bytes`, `12.3 KB`, `145 MB`,
100
- * `2.4 GB`. Returns `—` for invalid/missing values.
101
- *
102
- * @param {number | null | undefined} bytes
103
- * @returns {string}
104
- */
105
- export function formatBytes(bytes) {
106
- if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return '—';
107
- if (bytes < 1024) return `${bytes} B`;
108
- const kb = bytes / 1024;
109
- if (kb < 1024) return `${kb.toFixed(1)} KB`;
110
- const mb = kb / 1024;
111
- if (mb < 1024) return `${mb.toFixed(mb >= 100 ? 0 : 1)} MB`;
112
- const gb = mb / 1024;
113
- return `${gb.toFixed(gb >= 100 ? 0 : 2)} GB`;
114
- }
package/lib/mcp/server.js DELETED
@@ -1,67 +0,0 @@
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
- * @param {{ version?: string }} [opts] `version` is echoed back in the
16
- * MCP `initialize` response's `serverInfo.version`. Caller is expected
17
- * to pass the CLI's `package.json#version` so the two never drift.
18
- */
19
- export async function startMCPServer({ version = 'unknown' } = {}) {
20
- const config = readConfig();
21
- if (config.server) {
22
- setBaseURL(`https://${config.server}`);
23
- }
24
-
25
- const server = new Server(
26
- {
27
- name: 'thinr',
28
- version,
29
- },
30
- {
31
- capabilities: { tools: {} },
32
- },
33
- );
34
-
35
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
36
- tools: tools.map(({ handler: _handler, ...t }) => t),
37
- }));
38
-
39
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
40
- const name = request.params.name;
41
- const args = request.params.arguments || {};
42
- const tool = tools.find((t) => t.name === name);
43
- if (!tool) {
44
- return {
45
- content: [{ type: 'text', text: `Unknown tool: ${name}` }],
46
- isError: true,
47
- };
48
- }
49
- try {
50
- return await withProfile(args.profile, () => tool.handler(args));
51
- } catch (error) {
52
- // Surface the same {message, code} pair the CLI emits in JSON
53
- // mode so MCP clients can pattern-match on `[code]` instead of
54
- // doing fuzzy matches on the human message.
55
- const { message, code } = classifyError(error);
56
- return {
57
- content: [{ type: 'text', text: `Error [${code}]: ${message}` }],
58
- isError: true,
59
- _meta: { code, message },
60
- };
61
- }
62
- });
63
-
64
- const transport = new StdioServerTransport();
65
- await server.connect(transport);
66
- console.error('[thinr-mcp] Server started');
67
- }