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

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/README.md CHANGED
@@ -21,6 +21,12 @@ mcp-device install
21
21
  mcp-device status
22
22
  ```
23
23
 
24
+ `mcp-device login` stays terminal-only: it prints the pairing URL, user code, and QR code, and never opens a browser automatically.
25
+
26
+ ## Limited hosted access
27
+
28
+ The public source package remains MIT licensed, but access to the hosted `device.hcu-lab.me` gateway is intentionally limited. New hosted accounts are admitted through a small, controlled supply of invite codes rather than open registration. Invite availability may be capped, paused, expired, or revoked as the service capacity and security policy change. Installing the package does not by itself grant a hosted-service account or guarantee that an invite code will be available.
29
+
24
30
  The hosted Pair/Help pages render their gateway URL and navigation from the deployment that served the page, so staging or custom deployments do not need a production-host rewrite. For the canonical package, `https://device.hcu-lab.me` is the default gateway; `MCP_GATEWAY_URL` is only needed when intentionally using another gateway.
25
31
 
26
32
  ## Commands
@@ -41,6 +47,12 @@ mcp-device --help show command help
41
47
 
42
48
  On Windows, `install` uses current-user background registration. On Linux, every `install` asks whether to use `systemd --user` or an already configured PM2 installation. MCP Device does not install PM2, run `pm2 startup`, or use `sudo` automatically.
43
49
 
50
+ ## Dashboard updates
51
+
52
+ Starting with MCP Device 1.0.5, an installed background device reports its package version to the authenticated gateway and can accept a per-device update requested by its owning account from the dashboard. The control is intentionally narrow: the gateway supplies only an exact stable version, and the device installs only `@hcu-lab.me/mcp-device@<version>`, verifies the installed version, then restarts through its existing Windows, systemd-user, or PM2 lifecycle.
53
+
54
+ Installations older than 1.0.5 do not understand this update control. Upgrade those devices to 1.0.5 once with npm; subsequent releases can be applied from the dashboard. Offline devices remain unchanged until they are online and explicitly updated.
55
+
44
56
  ## First run and trust
45
57
 
46
58
  The official gateway base URL is `https://device.hcu-lab.me`. The official package pins the independently distributed application CA and requires protocol v2 before the first network connection. If that CA is absent or invalid, MCP Device fails closed instead of learning trust from the gateway it is about to contact or falling back to protocol v1.
@@ -63,6 +75,14 @@ npm run build
63
75
  node dist/mcp-device.js --help
64
76
  ```
65
77
 
78
+ Normal development pushes do not publish npm. After reviewed development work is committed on a clean `main`, a release is intentionally created with:
79
+
80
+ ```powershell
81
+ npm run release
82
+ ```
83
+
84
+ That command runs the test suite, bumps the patch version, synchronizes project version files, creates the release commit and `vX.Y.Z` tag, then atomically pushes `main` and the tag. The tag-triggered GitHub Actions workflow performs the npm publish through trusted OIDC publishing. Use `npm run release:minor` or `npm run release:major` only when that version change is intentional.
85
+
66
86
  For a custom development gateway, set `MCP_GATEWAY_URL`. Supply `MCP_GATEWAY_APP_CA_PATH` from an independent trusted source when using protocol v2. Non-loopback plaintext gateway URLs are rejected.
67
87
 
68
88
  ## Upstream execution engine
@@ -11,6 +11,7 @@ import { bootstrapOfficialGatewayTrust } from './official-trust.js';
11
11
  import { GatewayDeviceStatusStore } from './device-status.js';
12
12
  import { GatewayToolAdapter } from './gateway-tool-adapter.js';
13
13
  import { RuntimeOwner, acquireRuntimeOwner } from './runtime-owner.js';
14
+ import { installDevicePackageUpdate, scheduleDeviceRuntimeRestart } from './self-update.js';
14
15
  import { captureRemote } from '../utils/capture.js';
15
16
  export class MCPDevice {
16
17
  constructor() {
@@ -112,6 +113,11 @@ export class MCPDevice {
112
113
  appCaPem: gatewayConfig.appCaPem || undefined,
113
114
  adapter: new GatewayToolAdapter(this.desktop, { allowedRoots: gatewayConfig.allowedRoots }),
114
115
  agentVersion: process.env.npm_package_version,
116
+ onUpdateRequest: async (targetVersion) => {
117
+ const record = await identity.loadOrCreate();
118
+ await installDevicePackageUpdate(targetVersion);
119
+ scheduleDeviceRuntimeRestart({ deviceId: record.deviceId });
120
+ },
115
121
  onStatus: async (payload) => {
116
122
  const record = await identity.loadOrCreate();
117
123
  await gatewayStatus.update({
@@ -12,6 +12,7 @@ export interface GatewayChannelOptions {
12
12
  adapter: GatewayToolAdapter;
13
13
  agentVersion?: string;
14
14
  onStatus?: (payload: any) => void | Promise<void>;
15
+ onUpdateRequest?: (targetVersion: string) => void | Promise<void>;
15
16
  }
16
17
  export declare class GatewayDeviceChannel {
17
18
  private options;
@@ -39,6 +40,7 @@ export declare class GatewayDeviceChannel {
39
40
  private sendHelloV2;
40
41
  private handleMessage;
41
42
  logoutAccount(timeoutMs?: number): Promise<any>;
43
+ private handleUpdateRequest;
42
44
  private handleToolCall;
43
45
  private scheduleReconnect;
44
46
  private startHeartbeat;
@@ -4,6 +4,7 @@ import { GatewayDeviceIdentity } from './gateway-identity.js';
4
4
  import { DEVICE_INNER_TLS_SUBPROTOCOL, createClientInnerTls, createJsonFrameParser, encodeJsonFrame, exportDeviceAuthKeyingMaterial } from './gateway-secure-transport.js';
5
5
  import { GATEWAY_CAPABILITIES } from './gateway-tool-adapter.js';
6
6
  import { gatewaySocketUrl } from './gateway-url-policy.js';
7
+ import { VERSION } from '../version.js';
7
8
  const PROTOCOL_VERSION = 1;
8
9
  const PROTOCOL_VERSION_V2 = 2;
9
10
  const HEARTBEAT_MS = 20000;
@@ -138,6 +139,7 @@ export class GatewayDeviceChannel {
138
139
  timestamp: Date.now(),
139
140
  payload: {
140
141
  agent_version: this.options.agentVersion || 'mcp-device-1',
142
+ package_version: VERSION,
141
143
  hostname: os.hostname(),
142
144
  platform: process.platform,
143
145
  arch: process.arch,
@@ -160,6 +162,7 @@ export class GatewayDeviceChannel {
160
162
  timestamp: Date.now(),
161
163
  payload: {
162
164
  agent_version: this.options.agentVersion || 'mcp-device-1',
165
+ package_version: VERSION,
163
166
  hostname: os.hostname(),
164
167
  platform: process.platform,
165
168
  arch: process.arch,
@@ -173,7 +176,7 @@ export class GatewayDeviceChannel {
173
176
  const message = typeof raw === 'string' ? JSON.parse(raw) : raw;
174
177
  if (message?.protocol_version !== this.protocolVersion)
175
178
  throw new Error('Unsupported gateway protocol version');
176
- if (['auth_challenge', 'auth_ok', 'tool_call'].includes(String(message?.type || ''))) {
179
+ if (['auth_challenge', 'auth_ok', 'tool_call', 'device_update'].includes(String(message?.type || ''))) {
177
180
  const record = await this.identity.loadOrCreate();
178
181
  if (String(message?.device_id || '') !== record.deviceId)
179
182
  throw new Error('Gateway message device_id mismatch');
@@ -245,6 +248,14 @@ export class GatewayDeviceChannel {
245
248
  }
246
249
  return;
247
250
  }
251
+ if (message.type === 'device_update') {
252
+ if (this.connectionEpoch === undefined || this.authenticatedDeviceId === undefined)
253
+ throw new Error('Gateway device_update arrived before authentication');
254
+ if (String(message.connection_epoch) !== String(this.connectionEpoch))
255
+ throw new Error('Gateway device_update connection_epoch mismatch');
256
+ void this.handleUpdateRequest(message);
257
+ return;
258
+ }
248
259
  if (message.type !== 'tool_call')
249
260
  return;
250
261
  if (this.connectionEpoch === undefined || this.authenticatedDeviceId === undefined)
@@ -286,6 +297,41 @@ export class GatewayDeviceChannel {
286
297
  clearTimeout(timer);
287
298
  }
288
299
  }
300
+ async handleUpdateRequest(message) {
301
+ if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
302
+ return;
303
+ const requestId = String(message.request_id || '').trim();
304
+ const targetVersion = String(message.payload?.target_version || '').trim();
305
+ const sendStatus = (state, extra = {}) => {
306
+ try {
307
+ this.send({
308
+ protocol_version: this.protocolVersion,
309
+ type: 'device_update_status',
310
+ request_id: requestId,
311
+ device_id: this.authenticatedDeviceId,
312
+ connection_epoch: this.connectionEpoch,
313
+ timestamp: Date.now(),
314
+ payload: { state, target_version: targetVersion, package_version: VERSION, ...extra }
315
+ });
316
+ }
317
+ catch { /* reconnect state will make the final version authoritative */ }
318
+ };
319
+ if (!requestId || !/^\d+\.\d+\.\d+$/.test(targetVersion) || typeof this.options.onUpdateRequest !== 'function') {
320
+ sendStatus('failed', { code: 'DEVICE_UPDATE_UNSUPPORTED', message: 'Device update request is unsupported or invalid.' });
321
+ return;
322
+ }
323
+ sendStatus('accepted');
324
+ try {
325
+ await this.options.onUpdateRequest(targetVersion);
326
+ sendStatus('installed');
327
+ }
328
+ catch (error) {
329
+ sendStatus('failed', {
330
+ code: String(error?.code || 'DEVICE_UPDATE_FAILED').slice(0, 64),
331
+ message: String(error?.message || error).slice(0, 240)
332
+ });
333
+ }
334
+ }
289
335
  async handleToolCall(message) {
290
336
  if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
291
337
  throw new Error('Gateway tool_call arrived before authentication');
@@ -16,6 +16,7 @@ export interface GatewayPairingOptions {
16
16
  deviceName?: string;
17
17
  fetchFn?: typeof fetch;
18
18
  proxyAgent?: Agent;
19
+ /** @deprecated Pairing is intentionally terminal-only; this hook is ignored. */
19
20
  openBrowser?: (url: string) => Promise<unknown>;
20
21
  renderQr?: (value: string) => void;
21
22
  sleep?: (ms: number) => Promise<void>;
@@ -1,7 +1,6 @@
1
1
  import { createHash, randomBytes } from 'crypto';
2
2
  import { createRequire } from 'module';
3
3
  import os from 'os';
4
- import open from 'open';
5
4
  import fetch from 'cross-fetch';
6
5
  import { gatewayHttpBase } from './gateway-url-policy.js';
7
6
  const CLIENT_ID = 'mcp-device';
@@ -34,7 +33,6 @@ async function jsonResponse(response) {
34
33
  export async function pairGatewayDevice(options) {
35
34
  const baseUrl = gatewayHttpBase(options.gatewayUrl);
36
35
  const fetchFn = options.fetchFn || fetch;
37
- const openBrowser = options.openBrowser || (url => open(url));
38
36
  const renderQr = options.renderQr || defaultQrRenderer;
39
37
  const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
40
38
  const log = options.log || (value => console.log(value));
@@ -62,10 +60,6 @@ export async function pairGatewayDevice(options) {
62
60
  log(`Pair this device: ${verificationUrl}`);
63
61
  log(`Code: ${start.user_code}`);
64
62
  renderQr(verificationUrl);
65
- try {
66
- await openBrowser(verificationUrl);
67
- }
68
- catch { /* URL + QR remain usable */ }
69
63
  const intervalMs = Math.max(1000, Math.min(30000, Number(start.interval || 2) * 1000));
70
64
  const deadline = Date.now() + Math.max(1, Number(start.expires_in || 600)) * 1000;
71
65
  while (Date.now() < deadline) {
@@ -0,0 +1,20 @@
1
+ import { spawn as spawnCallback } from 'child_process';
2
+ export declare const MCP_DEVICE_PACKAGE = "@hcu-lab.me/mcp-device";
3
+ export declare function normalizeUpdateVersion(value: string): string;
4
+ export declare function installDevicePackageUpdate(targetVersion: string, options?: {
5
+ platform?: NodeJS.Platform | string;
6
+ execFile?: (file: string, args: string[]) => Promise<{
7
+ stdout?: string;
8
+ stderr?: string;
9
+ }>;
10
+ npmPath?: string;
11
+ }): Promise<string>;
12
+ export declare function buildWindowsRestartHelper(deviceId: string, currentPid?: number): string;
13
+ export declare function scheduleDeviceRuntimeRestart(options: {
14
+ deviceId: string;
15
+ platform?: NodeJS.Platform | string;
16
+ argv?: string[];
17
+ delayMs?: number;
18
+ spawn?: typeof spawnCallback;
19
+ exit?: (code?: number) => never | void;
20
+ }): void;
@@ -0,0 +1,87 @@
1
+ import { execFile as execFileCallback, spawn as spawnCallback } from 'child_process';
2
+ import path from 'path';
3
+ import { promisify } from 'util';
4
+ import { deviceStatePaths } from './device-state.js';
5
+ import { buildWindowsTaskName } from './windows-service.js';
6
+ const execFileAsync = promisify(execFileCallback);
7
+ export const MCP_DEVICE_PACKAGE = '@hcu-lab.me/mcp-device';
8
+ export function normalizeUpdateVersion(value) {
9
+ const version = String(value || '').trim();
10
+ if (!/^\d+\.\d+\.\d+$/.test(version))
11
+ throw new Error('Update target must be an exact stable semantic version.');
12
+ return version;
13
+ }
14
+ export async function installDevicePackageUpdate(targetVersion, options = {}) {
15
+ const version = normalizeUpdateVersion(targetVersion);
16
+ const platform = options.platform || process.platform;
17
+ const execFile = options.execFile || (async (file, args) => {
18
+ const result = await execFileAsync(file, args, { windowsHide: true, encoding: 'utf8' });
19
+ return { stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
20
+ });
21
+ const npmPath = options.npmPath || (platform === 'win32' ? 'npm.cmd' : 'npm');
22
+ await execFile(npmPath, ['install', '-g', `${MCP_DEVICE_PACKAGE}@${version}`, '--no-audit', '--no-fund']);
23
+ const listed = await execFile(npmPath, ['list', '-g', MCP_DEVICE_PACKAGE, '--depth=0', '--json']);
24
+ let actual = '';
25
+ try {
26
+ actual = String(JSON.parse(String(listed.stdout || '{}'))?.dependencies?.[MCP_DEVICE_PACKAGE]?.version || '');
27
+ }
28
+ catch {
29
+ throw new Error('Updated package version could not be verified.');
30
+ }
31
+ if (actual !== version)
32
+ throw new Error(`Updated package verification failed: expected ${version}, got ${actual || 'unknown'}.`);
33
+ return actual;
34
+ }
35
+ function serviceManager(argv) {
36
+ const value = argv.find(item => String(item).startsWith('--manager='));
37
+ return value ? String(value).slice('--manager='.length).trim().toLowerCase() : '';
38
+ }
39
+ export function buildWindowsRestartHelper(deviceId, currentPid = process.pid) {
40
+ const runnerPath = path.join(deviceStatePaths().root, 'run-device.ps1');
41
+ const taskName = buildWindowsTaskName(deviceId);
42
+ const psQuote = (value) => `'${String(value).replace(/'/g, "''")}'`;
43
+ return [
44
+ `$oldPid = ${Number(currentPid)}`,
45
+ 'for ($i = 0; $i -lt 80; $i++) {',
46
+ " if (-not (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) { break }",
47
+ ' Start-Sleep -Milliseconds 250',
48
+ '}',
49
+ `$taskName = ${psQuote(taskName)}`,
50
+ `$runner = ${psQuote(runnerPath)}`,
51
+ 'if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {',
52
+ ' Start-ScheduledTask -TaskName $taskName',
53
+ '} else {',
54
+ " Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File',$runner) -WindowStyle Hidden",
55
+ '}'
56
+ ].join('; ');
57
+ }
58
+ export function scheduleDeviceRuntimeRestart(options) {
59
+ const platform = options.platform || process.platform;
60
+ const argv = options.argv || process.argv;
61
+ const delayMs = Math.max(100, Number(options.delayMs ?? 700));
62
+ const exit = options.exit || process.exit;
63
+ if (!argv.includes('--service'))
64
+ throw new Error('Dashboard updates require an installed background MCP Device service.');
65
+ if (platform === 'linux') {
66
+ const manager = serviceManager(argv);
67
+ if (!['systemd', 'pm2'].includes(manager))
68
+ throw new Error('Linux dashboard update requires systemd or PM2 service ownership.');
69
+ const timer = setTimeout(() => exit(75), delayMs);
70
+ timer.unref?.();
71
+ return;
72
+ }
73
+ if (platform === 'win32') {
74
+ const spawn = options.spawn || spawnCallback;
75
+ const helper = buildWindowsRestartHelper(options.deviceId);
76
+ const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-Command', helper], {
77
+ detached: true,
78
+ stdio: 'ignore',
79
+ windowsHide: true
80
+ });
81
+ child.unref();
82
+ const timer = setTimeout(() => exit(0), delayMs);
83
+ timer.unref?.();
84
+ return;
85
+ }
86
+ throw new Error('Dashboard self-update is supported only on installed Windows and Linux devices.');
87
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "1.0.3";
1
+ export declare const VERSION = "1.0.5";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.0.3';
1
+ export const VERSION = '1.0.5';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hcu-lab.me/mcp-device",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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,6 +26,10 @@
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",
30
+ "release": "node scripts/release.js patch",
31
+ "release:minor": "node scripts/release.js minor",
32
+ "release:major": "node scripts/release.js major",
29
33
  "bump": "node scripts/sync-version.js --bump",
30
34
  "bump:minor": "node scripts/sync-version.js --bump --minor",
31
35
  "bump:major": "node scripts/sync-version.js --bump --major",