@hcu-lab.me/mcp-device 1.0.5 → 1.0.6

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.
@@ -205,6 +205,21 @@ export class TerminalManager {
205
205
  // Spawn the process with appropriate arguments
206
206
  const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);
207
207
  let output = '';
208
+ // spawn() reports resolution/launch failures asynchronously via 'error'.
209
+ // A listener-less ChildProcess error becomes an uncaughtException and kills
210
+ // the inherited MCP server. Attach before any early-return path and keep
211
+ // the listener for the process lifetime.
212
+ let forwardProcessError = null;
213
+ let pendingProcessError = null;
214
+ childProcess.on('error', (err) => {
215
+ if (forwardProcessError) {
216
+ forwardProcessError(err);
217
+ }
218
+ else {
219
+ pendingProcessError = err;
220
+ console.error(`Process error for "${command}": ${err.message}`);
221
+ }
222
+ });
208
223
  // Ensure childProcess.pid is defined before proceeding
209
224
  if (!childProcess.pid) {
210
225
  // Return a consistent error object instead of throwing
@@ -259,6 +274,31 @@ export class TerminalManager {
259
274
  }
260
275
  resolve(result);
261
276
  };
277
+ // Once resolveOnce exists, route runtime/spawn errors into the normal
278
+ // command result path so callers do not hang and the session is retired.
279
+ forwardProcessError = (err) => {
280
+ // A late ChildProcess 'error' can arrive after the caller has already
281
+ // received a live/background session. Do not silently retire that
282
+ // session; the normal 'exit' event remains authoritative for cleanup.
283
+ if (resolved) {
284
+ this.appendToLineBuffer(session, `\nProcess error: ${err.message}\n`);
285
+ console.error(`Process error for "${command}": ${err.message}`);
286
+ return;
287
+ }
288
+ if (childProcess.pid)
289
+ this.sessions.delete(childProcess.pid);
290
+ exitReason = 'process_exit';
291
+ resolveOnce({
292
+ pid: childProcess.pid ?? -1,
293
+ output: output + `\nProcess error: ${err.message}`,
294
+ isBlocked: false
295
+ });
296
+ };
297
+ // Spawn failures normally arrive on the next tick, but replay any error
298
+ // emitted between spawn() and resolver initialization.
299
+ if (pendingProcessError) {
300
+ forwardProcessError(pendingProcessError);
301
+ }
262
302
  childProcess.stdout.on('data', (data) => {
263
303
  const text = data.toString();
264
304
  const now = Date.now();
@@ -443,7 +443,7 @@ const postTelemetryPayload = async (endpoint, payload) => {
443
443
  export const capture = async (event, properties) => {
444
444
  // Tool calls fired programmatically by the widget UIs must produce zero
445
445
  // telemetry — drop every event raised while serving one.
446
- if (isInsideUiOriginCall()) {
446
+ if (isInsideUiOriginCall() || isTelemetryDisabledByEnv()) {
447
447
  return;
448
448
  }
449
449
  void (async () => {
@@ -5,6 +5,7 @@
5
5
  * Each handler implements canHandle() which can be sync (extension-based)
6
6
  * or async (content-based like BinaryFileHandler using isBinaryFile)
7
7
  */
8
+ import fs from 'fs/promises';
8
9
  import { TextFileHandler } from './text.js';
9
10
  import { ImageFileHandler } from './image.js';
10
11
  import { BinaryFileHandler } from './binary.js';
@@ -86,6 +87,25 @@ export async function getFileHandler(filePath) {
86
87
  if (getImageHandler().canHandle(filePath)) {
87
88
  return getImageHandler();
88
89
  }
90
+ // UTF-16LE text contains many NUL bytes and generic binary sniffers
91
+ // often classify it as binary. A BOM is authoritative, so route it to the
92
+ // text handler before content-based binary detection.
93
+ try {
94
+ const fd = await fs.open(filePath, 'r');
95
+ try {
96
+ const bom = Buffer.alloc(2);
97
+ const { bytesRead } = await fd.read(bom, 0, bom.length, 0);
98
+ if (bytesRead === 2 && bom[0] === 0xff && bom[1] === 0xfe) {
99
+ return getTextHandler();
100
+ }
101
+ }
102
+ finally {
103
+ await fd.close();
104
+ }
105
+ }
106
+ catch {
107
+ // Preserve existing fallback behavior if the file cannot be sampled.
108
+ }
89
109
  // Check Binary (content-based, async via isBinaryFile)
90
110
  if (await getBinaryHandler().canHandle(filePath)) {
91
111
  return getBinaryHandler();
@@ -19,6 +19,7 @@ import { FileHandler, ReadOptions, FileResult, FileInfo } from './base.js';
19
19
  */
20
20
  export declare class TextFileHandler implements FileHandler {
21
21
  canHandle(_path: string): boolean;
22
+ private detectTextEncoding;
22
23
  read(filePath: string, options?: ReadOptions): Promise<FileResult>;
23
24
  write(path: string, content: string, mode?: 'rewrite' | 'append'): Promise<void>;
24
25
  getInfo(path: string): Promise<FileInfo>;
@@ -38,6 +38,17 @@ export class TextFileHandler {
38
38
  // The factory routes binary files to BinaryFileHandler before reaching here
39
39
  return true;
40
40
  }
41
+ async detectTextEncoding(filePath) {
42
+ const fd = await fs.open(filePath, 'r');
43
+ try {
44
+ const bom = Buffer.alloc(2);
45
+ const { bytesRead } = await fd.read(bom, 0, bom.length, 0);
46
+ return bytesRead === 2 && bom[0] === 0xff && bom[1] === 0xfe ? 'utf16le' : 'utf8';
47
+ }
48
+ finally {
49
+ await fd.close();
50
+ }
51
+ }
41
52
  async read(filePath, options) {
42
53
  const offset = options?.offset ?? 0;
43
54
  const length = options?.length ?? 1000; // Default from config
@@ -69,7 +80,11 @@ export class TextFileHandler {
69
80
  // For text files that aren't too large, count lines
70
81
  if (stats.isFile() && stats.size < FILE_SIZE_LIMITS.LINE_COUNT_LIMIT) {
71
82
  try {
72
- const content = await fs.readFile(path, 'utf8');
83
+ const encoding = await this.detectTextEncoding(path);
84
+ let content = await fs.readFile(path, encoding);
85
+ if (encoding === 'utf16le' && content.charCodeAt(0) === 0xfeff) {
86
+ content = content.slice(1);
87
+ }
73
88
  const lineCount = TextFileHandler.countLines(content);
74
89
  info.metadata.lineCount = lineCount;
75
90
  }
@@ -100,11 +115,14 @@ export class TextFileHandler {
100
115
  /**
101
116
  * Get file line count (for files under size limit)
102
117
  */
103
- async getFileLineCount(filePath, signal) {
118
+ async getFileLineCount(filePath, signal, encoding = 'utf8') {
104
119
  try {
105
120
  const stats = await fs.stat(filePath);
106
121
  if (stats.size < FILE_SIZE_LIMITS.LINE_COUNT_LIMIT) {
107
- const content = await fs.readFile(filePath, { encoding: 'utf8', signal });
122
+ let content = await fs.readFile(filePath, { encoding, signal });
123
+ if (encoding === 'utf16le' && content.charCodeAt(0) === 0xfeff) {
124
+ content = content.slice(1);
125
+ }
108
126
  return TextFileHandler.countLines(content);
109
127
  }
110
128
  }
@@ -182,7 +200,16 @@ export class TextFileHandler {
182
200
  async readFileWithSmartPositioning(filePath, offset, length, mimeType, includeStatusMessage = true, signal) {
183
201
  const stats = await fs.stat(filePath);
184
202
  const fileSize = stats.size;
185
- const totalLines = await this.getFileLineCount(filePath, signal);
203
+ const encoding = await this.detectTextEncoding(filePath);
204
+ const totalLines = await this.getFileLineCount(filePath, signal, encoding);
205
+ // UTF-16LE needs character-aware streaming. Skip the BOM and preserve
206
+ // the same line pagination/status semantics as UTF-8.
207
+ if (encoding === 'utf16le') {
208
+ if (offset < 0) {
209
+ return await this.readFromEndWithReadline(filePath, Math.abs(offset), mimeType, includeStatusMessage, totalLines, signal, 'utf16le', 2);
210
+ }
211
+ return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal, 'utf16le', 2);
212
+ }
186
213
  // For negative offsets (tail behavior), use reverse reading
187
214
  if (offset < 0) {
188
215
  const requestedLines = Math.abs(offset);
@@ -252,9 +279,9 @@ export class TextFileHandler {
252
279
  /**
253
280
  * Read from end using readline with circular buffer
254
281
  */
255
- async readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage = true, fileTotalLines, signal) {
282
+ async readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage = true, fileTotalLines, signal, encoding, start) {
256
283
  const rl = createInterface({
257
- input: createReadStream(filePath, { signal }),
284
+ input: createReadStream(filePath, { signal, encoding, start }),
258
285
  crlfDelay: Infinity
259
286
  });
260
287
  const buffer = new Array(requestedLines);
@@ -284,22 +311,28 @@ export class TextFileHandler {
284
311
  /**
285
312
  * Read from start/middle using readline
286
313
  */
287
- async readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage = true, fileTotalLines, signal) {
314
+ async readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage = true, fileTotalLines, signal, encoding, start) {
315
+ const stream = createReadStream(filePath, { signal, encoding, start });
288
316
  const rl = createInterface({
289
- input: createReadStream(filePath, { signal }),
317
+ input: stream,
290
318
  crlfDelay: Infinity
291
319
  });
292
320
  const result = [];
293
321
  let lineNumber = 0;
294
- for await (const line of rl) {
295
- if (lineNumber >= offset && result.length < length) {
296
- result.push(line);
322
+ try {
323
+ for await (const line of rl) {
324
+ if (lineNumber >= offset && result.length < length) {
325
+ result.push(line);
326
+ }
327
+ if (result.length >= length)
328
+ break;
329
+ lineNumber++;
297
330
  }
298
- if (result.length >= length)
299
- break;
300
- lineNumber++;
301
331
  }
302
- rl.close();
332
+ finally {
333
+ rl.close();
334
+ stream.destroy();
335
+ }
303
336
  if (includeStatusMessage) {
304
337
  const statusMessage = this.generateEnhancedStatusMessage(result.length, offset, fileTotalLines, false);
305
338
  const content = `${statusMessage}\n\n${result.join('\n')}`;
@@ -314,20 +347,26 @@ export class TextFileHandler {
314
347
  * Read from estimated byte position for very large files
315
348
  */
316
349
  async readFromEstimatedPosition(filePath, offset, length, mimeType, includeStatusMessage = true, fileTotalLines, signal) {
317
- // First, do a quick scan to estimate lines per byte
350
+ // First, do a quick scan to estimate lines per byte.
351
+ const sampleStream = createReadStream(filePath, { signal });
318
352
  const rl = createInterface({
319
- input: createReadStream(filePath, { signal }),
353
+ input: sampleStream,
320
354
  crlfDelay: Infinity
321
355
  });
322
356
  let sampleLines = 0;
323
357
  let bytesRead = 0;
324
- for await (const line of rl) {
325
- bytesRead += Buffer.byteLength(line, 'utf-8') + 1;
326
- sampleLines++;
327
- if (bytesRead >= READ_PERFORMANCE_THRESHOLDS.SAMPLE_SIZE)
328
- break;
358
+ try {
359
+ for await (const line of rl) {
360
+ bytesRead += Buffer.byteLength(line, 'utf-8') + 1;
361
+ sampleLines++;
362
+ if (bytesRead >= READ_PERFORMANCE_THRESHOLDS.SAMPLE_SIZE)
363
+ break;
364
+ }
365
+ }
366
+ finally {
367
+ rl.close();
368
+ sampleStream.destroy();
329
369
  }
330
- rl.close();
331
370
  if (sampleLines === 0) {
332
371
  return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, fileTotalLines, signal);
333
372
  }
@@ -345,19 +384,24 @@ export class TextFileHandler {
345
384
  });
346
385
  const result = [];
347
386
  let firstLineSkipped = false;
348
- for await (const line of rl2) {
349
- if (!firstLineSkipped && startPosition > 0) {
350
- firstLineSkipped = true;
351
- continue;
352
- }
353
- if (result.length < length) {
354
- result.push(line);
355
- }
356
- else {
357
- break;
387
+ try {
388
+ for await (const line of rl2) {
389
+ if (!firstLineSkipped && startPosition > 0) {
390
+ firstLineSkipped = true;
391
+ continue;
392
+ }
393
+ if (result.length < length) {
394
+ result.push(line);
395
+ }
396
+ else {
397
+ break;
398
+ }
358
399
  }
359
400
  }
360
- rl2.close();
401
+ finally {
402
+ rl2.close();
403
+ stream.destroy();
404
+ }
361
405
  const content = includeStatusMessage
362
406
  ? `${this.generateEnhancedStatusMessage(result.length, offset, fileTotalLines, false)}\n\n${result.join('\n')}`
363
407
  : result.join('\n');
@@ -13,8 +13,29 @@ export async function trackToolCall(toolName, args) {
13
13
  try {
14
14
  // Get current timestamp
15
15
  const timestamp = new Date().toISOString();
16
- // Format the log entry
17
- const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\t| Arguments: ${JSON.stringify(args)}` : ''}\n`;
16
+ // Remote MCP Device calls may contain commands, file contents, tokens or
17
+ // other secrets. Persist only bounded metadata in remote mode.
18
+ const remote = process.env.MCP_DEVICE_REMOTE === 'true';
19
+ let suffix = '';
20
+ if (args !== undefined) {
21
+ if (remote) {
22
+ let argBytes = 0;
23
+ let argCount = 0;
24
+ try {
25
+ const serialized = JSON.stringify(args);
26
+ argBytes = Buffer.byteLength(serialized || '', 'utf8');
27
+ argCount = args && typeof args === 'object' && !Array.isArray(args)
28
+ ? Object.keys(args).length
29
+ : 1;
30
+ }
31
+ catch { /* metadata-only logging must never affect tool execution */ }
32
+ suffix = `\t| Args: count=${argCount} bytes=${argBytes}`;
33
+ }
34
+ else {
35
+ suffix = `\t| Arguments: ${JSON.stringify(args)}`;
36
+ }
37
+ }
38
+ const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${suffix}\n`;
18
39
  // Check if file exists and get its size
19
40
  let fileSize = 0;
20
41
  try {
@@ -52,6 +52,8 @@ class UsageTracker {
52
52
  * keeps a saturated libuv threadpool from gating tool responses on every call.
53
53
  */
54
54
  async saveStats(stats) {
55
+ if (process.env.MCP_DEVICE_REMOTE === 'true')
56
+ return;
55
57
  await configManager.setValueNonBlocking('usageStats', stats);
56
58
  }
57
59
  /**
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "1.0.5";
1
+ export declare const VERSION = "1.0.6";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.0.5';
1
+ export const VERSION = '1.0.6';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hcu-lab.me/mcp-device",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "MCP Device securely connects a user-owned computer to an authenticated Model Context Protocol gateway.",
5
5
  "license": "MIT",
6
6
  "author": "Hirohimeow",
@@ -26,14 +26,14 @@
26
26
  "device:start": "tsx src/device/device.ts",
27
27
  "device:start:dev": "nodemon --watch src/device --exec tsx src/device/device.ts",
28
28
  "sync-version": "node scripts/sync-version.js",
29
- "version": "node scripts/sync-version.js && git add server.json src/version.ts package-lock.json",
29
+ "version": "node scripts/sync-version.js && git add src/version.ts package-lock.json",
30
30
  "release": "node scripts/release.js patch",
31
31
  "release:minor": "node scripts/release.js minor",
32
32
  "release:major": "node scripts/release.js major",
33
33
  "bump": "node scripts/sync-version.js --bump",
34
34
  "bump:minor": "node scripts/sync-version.js --bump --minor",
35
35
  "bump:major": "node scripts/sync-version.js --bump --major",
36
- "build": "shx rm -rf dist/remote-device && shx rm -f dist/device/desktop-commander-integration.js dist/device/desktop-commander-integration.d.ts dist/device/windows-dpapi.js dist/device/windows-dpapi.d.ts && tsc && shx chmod +x dist/*.js && shx mkdir -p dist/data && shx cp src/data/onboarding-prompts.json dist/data/ && node scripts/copy-official-ca.cjs && node scripts/build-ui-runtime.cjs",
36
+ "build": "shx rm -rf dist/remote-device && shx rm -f dist/device/desktop-commander-integration.js dist/device/desktop-commander-integration.d.ts dist/device/windows-dpapi.js dist/device/windows-dpapi.d.ts && tsc && shx chmod +x dist/*.js && shx cp src/device/update-helper.cjs dist/device/update-helper.cjs && shx mkdir -p dist/data && shx cp src/data/onboarding-prompts.json dist/data/ && node scripts/copy-official-ca.cjs && node scripts/build-ui-runtime.cjs",
37
37
  "watch": "tsc --watch",
38
38
  "start": "node dist/mcp-device.js",
39
39
  "start:debug": "node --inspect-brk=9229 dist/mcp-device.js",