@hcu-lab.me/mcp-device 1.0.5 → 1.0.7
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 +7 -71
- package/dist/bootstrap.js +1 -1
- package/dist/config-manager.js +56 -19
- package/dist/config.js +1 -1
- package/dist/custom-stdio.js +1 -2
- package/dist/data/onboarding-prompts.json +22 -11
- package/dist/device/device-state.d.ts +2 -5
- package/dist/device/device-state.js +3 -85
- package/dist/device/device.d.ts +1 -1
- package/dist/device/device.js +69 -9
- package/dist/device/execution-engine.d.ts +11 -1
- package/dist/device/execution-engine.js +85 -126
- package/dist/device/gateway-channel.d.ts +19 -1
- package/dist/device/gateway-channel.js +107 -21
- package/dist/device/gateway-identity.d.ts +1 -0
- package/dist/device/gateway-identity.js +4 -0
- package/dist/device/gateway-tool-adapter.d.ts +3 -3
- package/dist/device/gateway-tool-adapter.js +12 -11
- package/dist/device/linux-service.d.ts +6 -0
- package/dist/device/linux-service.js +12 -5
- package/dist/device/runtime-owner.d.ts +1 -0
- package/dist/device/runtime-owner.js +6 -6
- package/dist/device/self-update.d.ts +107 -12
- package/dist/device/self-update.js +660 -62
- package/dist/device/update-helper.cjs +343 -0
- package/dist/device/windows-service.d.ts +3 -4
- package/dist/device/windows-service.js +5 -44
- package/dist/handlers/filesystem-handlers.js +3 -0
- package/dist/index.js +7 -3
- package/dist/npm-scripts/remote.d.ts +5 -0
- package/dist/npm-scripts/remote.js +50 -3
- package/dist/server.js +83 -167
- package/dist/terminal-manager.js +41 -1
- package/dist/tools/config.d.ts +1 -1
- package/dist/tools/filesystem.js +3 -3
- package/dist/tools/pdf/markdown.js +1 -1
- package/dist/tools/schemas.d.ts +2 -3
- package/dist/tools/schemas.js +0 -10
- package/dist/ui/config-editor/index.html +1 -1
- package/dist/ui/file-preview/index.html +1 -1
- package/dist/ui/file-preview/styles.css +2 -2
- package/dist/utils/capture.d.ts +5 -28
- package/dist/utils/capture.js +11 -455
- package/dist/utils/feature-flags.d.ts +0 -47
- package/dist/utils/feature-flags.js +5 -199
- package/dist/utils/files/docx.d.ts +2 -2
- package/dist/utils/files/docx.js +2 -2
- package/dist/utils/files/factory.js +20 -0
- package/dist/utils/files/text.d.ts +1 -0
- package/dist/utils/files/text.js +78 -34
- package/dist/utils/trackTools.js +23 -2
- package/dist/utils/usageTracker.js +2 -24
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -7
- package/dist/tools/feedback.d.ts +0 -8
- package/dist/tools/feedback.js +0 -132
|
@@ -1,135 +1,116 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
5
|
import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { captureRemote } from '../utils/capture.js';
|
|
8
|
+
import { deviceStatePaths } from './device-state.js';
|
|
8
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
10
|
const __dirname = path.dirname(__filename);
|
|
10
|
-
export class
|
|
11
|
+
export class LocalExecutionEngine {
|
|
11
12
|
constructor() {
|
|
12
13
|
this.mcpClient = null;
|
|
13
14
|
this.mcpTransport = null;
|
|
14
15
|
this.isReady = false;
|
|
16
|
+
this.runtimeReason = 'LOCAL_EXECUTION_ENGINE_UNAVAILABLE';
|
|
17
|
+
this.runtimeGeneration = null;
|
|
18
|
+
}
|
|
19
|
+
markUnavailable(client) {
|
|
20
|
+
if (client && this.mcpClient !== client)
|
|
21
|
+
return;
|
|
22
|
+
this.isReady = false;
|
|
23
|
+
this.runtimeReason = 'LOCAL_EXECUTION_ENGINE_UNAVAILABLE';
|
|
24
|
+
this.runtimeGeneration = null;
|
|
15
25
|
}
|
|
16
26
|
async initialize() {
|
|
17
|
-
console.debug('[DEBUG]
|
|
18
|
-
|
|
19
|
-
if (!config) {
|
|
20
|
-
console.debug('[DEBUG] No MCP config found');
|
|
21
|
-
throw new Error('Desktop Commander MCP not found. Please install it globally via `npm install -g @wonderwhy-er/desktop-commander` or build the local project.');
|
|
22
|
-
}
|
|
23
|
-
console.log(` - â³ Connecting to Local Desktop Commander MCP using: ${config.command} ${config.args.join(' ')}`);
|
|
24
|
-
console.debug('[DEBUG] MCP config:', JSON.stringify(config, null, 2));
|
|
27
|
+
console.debug('[DEBUG] LocalExecutionEngine.initialize() called');
|
|
28
|
+
this.markUnavailable();
|
|
25
29
|
try {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
this.mcpTransport = new StdioClientTransport({
|
|
30
|
+
const config = await this.resolveMcpConfig();
|
|
31
|
+
if (!config)
|
|
32
|
+
throw new Error('Bundled local execution engine is unavailable. Reinstall MCP Device.');
|
|
33
|
+
const transport = new StdioClientTransport({
|
|
31
34
|
...config,
|
|
32
35
|
env: {
|
|
33
36
|
...getDefaultEnvironment(),
|
|
34
37
|
...config.env,
|
|
35
|
-
MCP_DEVICE_REMOTE: 'true'
|
|
36
|
-
DESKTOP_COMMANDER_DISABLE_TELEMETRY: 'true'
|
|
38
|
+
MCP_DEVICE_REMOTE: 'true'
|
|
37
39
|
}
|
|
38
40
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
this.mcpClient =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
console.debug('[DEBUG] Connecting MCP client to transport');
|
|
49
|
-
await this.mcpClient.connect(this.mcpTransport);
|
|
41
|
+
const client = new Client({ name: 'mcp-device-local-engine', version: '1.0.0' }, { capabilities: {} });
|
|
42
|
+
this.mcpTransport = transport;
|
|
43
|
+
this.mcpClient = client;
|
|
44
|
+
client.onclose = () => this.markUnavailable(client);
|
|
45
|
+
await client.connect(transport);
|
|
46
|
+
if (this.mcpClient !== client || this.mcpTransport !== transport || transport.pid === null) {
|
|
47
|
+
throw new Error('Local execution engine closed during startup.');
|
|
48
|
+
}
|
|
49
|
+
this.runtimeGeneration = randomUUID();
|
|
50
50
|
this.isReady = true;
|
|
51
|
-
|
|
52
|
-
console.
|
|
51
|
+
this.runtimeReason = null;
|
|
52
|
+
console.log(' - Local execution engine ready');
|
|
53
53
|
}
|
|
54
54
|
catch (error) {
|
|
55
|
-
console.error(' -
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
console.error(' - Failed to start local execution engine:', error);
|
|
56
|
+
await captureRemote('local_engine_init_failed', { error });
|
|
57
|
+
try {
|
|
58
|
+
await this.mcpClient?.close();
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
try {
|
|
62
|
+
await this.mcpTransport?.close();
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
this.mcpClient = null;
|
|
66
|
+
this.mcpTransport = null;
|
|
67
|
+
this.markUnavailable();
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
async resolveMcpConfig() {
|
|
62
|
-
|
|
63
|
-
// Option 1: Development/Local Build
|
|
64
|
-
// Adjusting path resolution since we are now in src/device and dist is in root/dist
|
|
65
|
-
// Original: path.resolve(__dirname, '../../dist/index.js')
|
|
66
|
-
const devPath = path.resolve(__dirname, '../../dist/index.js');
|
|
67
|
-
console.debug('[DEBUG] Checking local dev path:', devPath);
|
|
71
|
+
const localEngine = path.resolve(__dirname, '../../dist/index.js');
|
|
68
72
|
try {
|
|
69
|
-
await fs.access(
|
|
70
|
-
console.debug(' - 🔠Found local MCP server at:', devPath);
|
|
71
|
-
return {
|
|
72
|
-
command: process.execPath, // Use the current node executable
|
|
73
|
-
args: [devPath],
|
|
74
|
-
cwd: path.dirname(devPath)
|
|
75
|
-
};
|
|
73
|
+
await fs.access(localEngine);
|
|
76
74
|
}
|
|
77
75
|
catch {
|
|
78
|
-
|
|
79
|
-
// Local file not found, continue...
|
|
76
|
+
return null;
|
|
80
77
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
catch (err) {
|
|
107
|
-
console.debug('[DEBUG] Global command not found:', err);
|
|
108
|
-
// Global command not found
|
|
109
|
-
}
|
|
110
|
-
console.debug('[DEBUG] No MCP config resolved');
|
|
111
|
-
return null;
|
|
78
|
+
const runtimeDir = deviceStatePaths().runtime;
|
|
79
|
+
await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
|
|
80
|
+
return {
|
|
81
|
+
command: process.execPath,
|
|
82
|
+
args: [localEngine],
|
|
83
|
+
cwd: runtimeDir
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
getChildPid() {
|
|
87
|
+
return this.mcpTransport?.pid ?? null;
|
|
88
|
+
}
|
|
89
|
+
getRuntimeState() {
|
|
90
|
+
return {
|
|
91
|
+
runtime_ready: this.isReady,
|
|
92
|
+
runtime_reason: this.isReady ? null : this.runtimeReason,
|
|
93
|
+
execution_runtime_generation: this.isReady ? this.runtimeGeneration : null
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
assertReady() {
|
|
97
|
+
if (this.isReady && this.mcpClient)
|
|
98
|
+
return;
|
|
99
|
+
const error = new Error('Local execution engine is unavailable.');
|
|
100
|
+
error.code = 'DEVICE_NOT_READY';
|
|
101
|
+
throw error;
|
|
112
102
|
}
|
|
113
103
|
async callClientTool(toolName, args, metadata) {
|
|
114
|
-
|
|
115
|
-
console.debug('[DEBUG] callClientTool() failed - not ready or no client');
|
|
116
|
-
throw new Error('DesktopIntegration not initialized');
|
|
117
|
-
}
|
|
118
|
-
// Proxy other tools to MCP server
|
|
104
|
+
this.assertReady();
|
|
119
105
|
try {
|
|
120
|
-
|
|
121
|
-
const result = await this.mcpClient.callTool({
|
|
106
|
+
return await this.mcpClient.callTool({
|
|
122
107
|
name: toolName,
|
|
123
108
|
arguments: args,
|
|
124
109
|
_meta: { remote: true, ...metadata || {} }
|
|
125
110
|
});
|
|
126
|
-
console.debug('[DEBUG] Tool call successful:', toolName);
|
|
127
|
-
return result;
|
|
128
111
|
}
|
|
129
112
|
catch (error) {
|
|
130
|
-
|
|
131
|
-
console.debug('[DEBUG] Tool call error details:', error);
|
|
132
|
-
await captureRemote('desktop_integration_tool_call_failed', { error, toolName });
|
|
113
|
+
await captureRemote('local_engine_tool_call_failed', { error, toolName });
|
|
133
114
|
throw error;
|
|
134
115
|
}
|
|
135
116
|
}
|
|
@@ -137,59 +118,37 @@ export class DesktopCommanderIntegration {
|
|
|
137
118
|
if (!this.mcpClient)
|
|
138
119
|
return { tools: [] };
|
|
139
120
|
try {
|
|
140
|
-
// List tools from MCP server
|
|
141
121
|
const mcpTools = await this.mcpClient.listTools();
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
tools: mcpTools.tools || []
|
|
145
|
-
};
|
|
122
|
+
return { tools: mcpTools.tools || [] };
|
|
146
123
|
}
|
|
147
124
|
catch (error) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
// Fallback to local tools
|
|
151
|
-
return {
|
|
152
|
-
tools: []
|
|
153
|
-
};
|
|
125
|
+
await captureRemote('local_engine_list_tools_failed', { error });
|
|
126
|
+
return { tools: [] };
|
|
154
127
|
}
|
|
155
128
|
}
|
|
156
129
|
async shutdown() {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`${name} timeout after ${timeoutMs}ms`)), timeoutMs))
|
|
162
|
-
]);
|
|
163
|
-
};
|
|
130
|
+
const closeWithTimeout = async (operation, name, timeoutMs = 3000) => Promise.race([
|
|
131
|
+
operation(),
|
|
132
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`${name} timeout after ${timeoutMs}ms`)), timeoutMs))
|
|
133
|
+
]);
|
|
164
134
|
if (this.mcpClient) {
|
|
165
135
|
try {
|
|
166
|
-
console.log(' → Closing MCP client...');
|
|
167
|
-
console.debug('[DEBUG] Calling mcpClient.close() with timeout');
|
|
168
136
|
await closeWithTimeout(() => this.mcpClient.close(), 'MCP client close');
|
|
169
|
-
console.log(' ✓ MCP client closed');
|
|
170
137
|
}
|
|
171
|
-
catch (
|
|
172
|
-
|
|
173
|
-
console.debug('[DEBUG] MCP client close error:', e);
|
|
174
|
-
await captureRemote('desktop_integration_shutdown_error', { error: e, component: 'client' });
|
|
138
|
+
catch (error) {
|
|
139
|
+
await captureRemote('local_engine_shutdown_error', { error, component: 'client' });
|
|
175
140
|
}
|
|
176
141
|
this.mcpClient = null;
|
|
177
142
|
}
|
|
178
143
|
if (this.mcpTransport) {
|
|
179
144
|
try {
|
|
180
|
-
console.log(' → Closing MCP transport...');
|
|
181
|
-
console.debug('[DEBUG] Calling mcpTransport.close() with timeout');
|
|
182
145
|
await closeWithTimeout(() => this.mcpTransport.close(), 'MCP transport close');
|
|
183
|
-
console.log(' ✓ MCP transport closed');
|
|
184
146
|
}
|
|
185
|
-
catch (
|
|
186
|
-
|
|
187
|
-
console.debug('[DEBUG] MCP transport close error:', e);
|
|
188
|
-
await captureRemote('desktop_integration_shutdown_error', { error: e, component: 'transport' });
|
|
147
|
+
catch (error) {
|
|
148
|
+
await captureRemote('local_engine_shutdown_error', { error, component: 'transport' });
|
|
189
149
|
}
|
|
190
150
|
this.mcpTransport = null;
|
|
191
151
|
}
|
|
192
|
-
this.
|
|
193
|
-
console.debug('[DEBUG] Desktop Commander integration shutdown complete');
|
|
152
|
+
this.markUnavailable();
|
|
194
153
|
}
|
|
195
154
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Agent } from 'http';
|
|
2
2
|
import { GatewayDeviceIdentity } from './gateway-identity.js';
|
|
3
3
|
import { GatewayToolAdapter } from './gateway-tool-adapter.js';
|
|
4
|
+
export type GatewayUpdateRequestOutcome = {
|
|
5
|
+
mode: 'detached';
|
|
6
|
+
handoff: () => Promise<void>;
|
|
7
|
+
};
|
|
4
8
|
export interface GatewayChannelOptions {
|
|
5
9
|
gatewayUrl: string;
|
|
6
10
|
enrollmentToken?: string;
|
|
@@ -10,9 +14,16 @@ export interface GatewayChannelOptions {
|
|
|
10
14
|
securityProtocolFloor?: number;
|
|
11
15
|
appCaPem?: string;
|
|
12
16
|
adapter: GatewayToolAdapter;
|
|
17
|
+
runtimeState?: () => {
|
|
18
|
+
runtime_ready: boolean;
|
|
19
|
+
runtime_reason: string | null;
|
|
20
|
+
execution_runtime_generation: string | null;
|
|
21
|
+
};
|
|
13
22
|
agentVersion?: string;
|
|
14
23
|
onStatus?: (payload: any) => void | Promise<void>;
|
|
15
|
-
onUpdateRequest?: (targetVersion: string
|
|
24
|
+
onUpdateRequest?: (targetVersion: string, context: {
|
|
25
|
+
requestId: string;
|
|
26
|
+
}) => GatewayUpdateRequestOutcome | Promise<GatewayUpdateRequestOutcome>;
|
|
16
27
|
}
|
|
17
28
|
export declare class GatewayDeviceChannel {
|
|
18
29
|
private options;
|
|
@@ -32,7 +43,9 @@ export declare class GatewayDeviceChannel {
|
|
|
32
43
|
private protocolVersion;
|
|
33
44
|
private secureSocket?;
|
|
34
45
|
private authMode;
|
|
46
|
+
private updateInFlight?;
|
|
35
47
|
constructor(options: GatewayChannelOptions);
|
|
48
|
+
private runtimePayload;
|
|
36
49
|
start(): Promise<void>;
|
|
37
50
|
private startV2Transport;
|
|
38
51
|
stop(): Promise<void>;
|
|
@@ -40,11 +53,16 @@ export declare class GatewayDeviceChannel {
|
|
|
40
53
|
private sendHelloV2;
|
|
41
54
|
private handleMessage;
|
|
42
55
|
logoutAccount(timeoutMs?: number): Promise<any>;
|
|
56
|
+
private updateStatusMessage;
|
|
57
|
+
private sendUpdateStatus;
|
|
58
|
+
private sendUpdateStatusFlushed;
|
|
59
|
+
reportUpdateFailure(requestId: string, targetVersion: string, code: string, message: string): Promise<void>;
|
|
43
60
|
private handleUpdateRequest;
|
|
44
61
|
private handleToolCall;
|
|
45
62
|
private scheduleReconnect;
|
|
46
63
|
private startHeartbeat;
|
|
47
64
|
private stopHeartbeat;
|
|
48
65
|
private send;
|
|
66
|
+
private sendFlushed;
|
|
49
67
|
private fail;
|
|
50
68
|
}
|
|
@@ -19,6 +19,19 @@ export class GatewayDeviceChannel {
|
|
|
19
19
|
this.authMode = 'reconnect';
|
|
20
20
|
this.identity = options.identity || new GatewayDeviceIdentity();
|
|
21
21
|
}
|
|
22
|
+
runtimePayload() {
|
|
23
|
+
if (!this.options.runtimeState)
|
|
24
|
+
return {};
|
|
25
|
+
const state = this.options.runtimeState();
|
|
26
|
+
const ready = state?.runtime_ready === true;
|
|
27
|
+
const reason = ready ? null : String(state?.runtime_reason || 'LOCAL_EXECUTION_ENGINE_UNAVAILABLE').slice(0, 96);
|
|
28
|
+
const generation = String(state?.execution_runtime_generation || '').trim().slice(0, 128) || null;
|
|
29
|
+
return {
|
|
30
|
+
runtime_ready: ready,
|
|
31
|
+
runtime_reason: reason,
|
|
32
|
+
execution_runtime_generation: generation
|
|
33
|
+
};
|
|
34
|
+
}
|
|
22
35
|
async start() {
|
|
23
36
|
this.authenticatedDeviceId = undefined;
|
|
24
37
|
this.connectionEpoch = undefined;
|
|
@@ -55,10 +68,15 @@ export class GatewayDeviceChannel {
|
|
|
55
68
|
socket.once('error', error => this.fail(error));
|
|
56
69
|
socket.once('close', (code, reason) => {
|
|
57
70
|
this.stopHeartbeat();
|
|
71
|
+
const reasonText = reason.toString();
|
|
72
|
+
const forgotten = code === 4003 && /(?:unknown or revoked device|revoked device|device revoked)/i.test(reasonText);
|
|
73
|
+
const error = new Error(`Gateway connection closed (${code}): ${reasonText}`);
|
|
74
|
+
if (forgotten)
|
|
75
|
+
error.code = 'DEVICE_FORGOTTEN';
|
|
58
76
|
if (!this.shuttingDown && this.readyReject) {
|
|
59
|
-
this.fail(
|
|
77
|
+
this.fail(error);
|
|
60
78
|
}
|
|
61
|
-
else if (!this.shuttingDown) {
|
|
79
|
+
else if (!this.shuttingDown && !forgotten) {
|
|
62
80
|
this.scheduleReconnect();
|
|
63
81
|
}
|
|
64
82
|
});
|
|
@@ -145,6 +163,7 @@ export class GatewayDeviceChannel {
|
|
|
145
163
|
arch: process.arch,
|
|
146
164
|
path_style: process.platform === 'win32' ? 'windows' : 'posix',
|
|
147
165
|
capabilities: [...GATEWAY_CAPABILITIES],
|
|
166
|
+
...this.runtimePayload(),
|
|
148
167
|
...(enrolling || pairing ? { public_key_pem: record.publicKeyPem } : {})
|
|
149
168
|
}
|
|
150
169
|
});
|
|
@@ -168,6 +187,7 @@ export class GatewayDeviceChannel {
|
|
|
168
187
|
arch: process.arch,
|
|
169
188
|
path_style: process.platform === 'win32' ? 'windows' : 'posix',
|
|
170
189
|
capabilities: [...GATEWAY_CAPABILITIES],
|
|
190
|
+
...this.runtimePayload(),
|
|
171
191
|
...(pairing || enrolling ? { public_key_pem: record.publicKeyPem, enrollment_grant: grant } : {})
|
|
172
192
|
}
|
|
173
193
|
});
|
|
@@ -297,40 +317,83 @@ export class GatewayDeviceChannel {
|
|
|
297
317
|
clearTimeout(timer);
|
|
298
318
|
}
|
|
299
319
|
}
|
|
320
|
+
updateStatusMessage(requestId, targetVersion, state, extra = {}) {
|
|
321
|
+
if (this.connectionEpoch === undefined || !this.authenticatedDeviceId) {
|
|
322
|
+
throw new Error('Gateway update status cannot be sent before authentication.');
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
protocol_version: this.protocolVersion,
|
|
326
|
+
type: 'device_update_status',
|
|
327
|
+
request_id: requestId,
|
|
328
|
+
device_id: this.authenticatedDeviceId,
|
|
329
|
+
connection_epoch: this.connectionEpoch,
|
|
330
|
+
timestamp: Date.now(),
|
|
331
|
+
payload: { state, target_version: targetVersion, package_version: VERSION, ...extra }
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
sendUpdateStatus(requestId, targetVersion, state, extra = {}) {
|
|
335
|
+
try {
|
|
336
|
+
this.send(this.updateStatusMessage(requestId, targetVersion, state, extra));
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// Best-effort path used only when the socket may already be closing.
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async sendUpdateStatusFlushed(requestId, targetVersion, state, extra = {}) {
|
|
343
|
+
await this.sendFlushed(this.updateStatusMessage(requestId, targetVersion, state, extra));
|
|
344
|
+
}
|
|
345
|
+
async reportUpdateFailure(requestId, targetVersion, code, message) {
|
|
346
|
+
// Failed update state is retired only after this awaited send succeeds.
|
|
347
|
+
await this.sendUpdateStatusFlushed(requestId, targetVersion, 'failed', {
|
|
348
|
+
code: String(code || 'DEVICE_UPDATE_FAILED').slice(0, 64),
|
|
349
|
+
message: String(message || 'Device update failed.').slice(0, 240)
|
|
350
|
+
});
|
|
351
|
+
}
|
|
300
352
|
async handleUpdateRequest(message) {
|
|
301
353
|
if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
|
|
302
354
|
return;
|
|
303
355
|
const requestId = String(message.request_id || '').trim();
|
|
304
356
|
const targetVersion = String(message.payload?.target_version || '').trim();
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
357
|
+
if (!requestId || !/^\d+\.\d+\.\d+$/.test(targetVersion) || typeof this.options.onUpdateRequest !== 'function') {
|
|
358
|
+
this.sendUpdateStatus(requestId, targetVersion, 'failed', {
|
|
359
|
+
code: 'DEVICE_UPDATE_UNSUPPORTED',
|
|
360
|
+
message: 'Device update request is unsupported or invalid.'
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (this.updateInFlight) {
|
|
365
|
+
if (this.updateInFlight !== requestId) {
|
|
366
|
+
this.sendUpdateStatus(requestId, targetVersion, 'failed', {
|
|
367
|
+
code: 'DEVICE_UPDATE_IN_PROGRESS',
|
|
368
|
+
message: 'Another MCP Device update is already in progress.'
|
|
315
369
|
});
|
|
316
370
|
}
|
|
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
371
|
return;
|
|
322
372
|
}
|
|
323
|
-
|
|
373
|
+
this.updateInFlight = requestId;
|
|
324
374
|
try {
|
|
325
|
-
|
|
326
|
-
|
|
375
|
+
// Pre-stage and verify the target before acknowledging the update.
|
|
376
|
+
// If this throws, the currently running device stays healthy.
|
|
377
|
+
const outcome = await this.options.onUpdateRequest(targetVersion, { requestId });
|
|
378
|
+
if (!outcome || outcome.mode !== 'detached') {
|
|
379
|
+
throw Object.assign(new Error('Device update handler did not provide a detached handoff.'), { code: 'DEVICE_UPDATE_INVALID_HANDOFF' });
|
|
380
|
+
}
|
|
381
|
+
// Do not tear down the socket until the gateway has accepted
|
|
382
|
+
// the frame into the transport write path.
|
|
383
|
+
await this.sendUpdateStatusFlushed(requestId, targetVersion, 'accepted');
|
|
384
|
+
await outcome.handoff();
|
|
385
|
+
return;
|
|
327
386
|
}
|
|
328
387
|
catch (error) {
|
|
329
|
-
|
|
388
|
+
this.sendUpdateStatus(requestId, targetVersion, 'failed', {
|
|
330
389
|
code: String(error?.code || 'DEVICE_UPDATE_FAILED').slice(0, 64),
|
|
331
390
|
message: String(error?.message || error).slice(0, 240)
|
|
332
391
|
});
|
|
333
392
|
}
|
|
393
|
+
finally {
|
|
394
|
+
if (this.updateInFlight === requestId)
|
|
395
|
+
this.updateInFlight = undefined;
|
|
396
|
+
}
|
|
334
397
|
}
|
|
335
398
|
async handleToolCall(message) {
|
|
336
399
|
if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
|
|
@@ -384,7 +447,7 @@ export class GatewayDeviceChannel {
|
|
|
384
447
|
type: 'heartbeat',
|
|
385
448
|
device_id: record.deviceId,
|
|
386
449
|
timestamp: Date.now(),
|
|
387
|
-
payload:
|
|
450
|
+
payload: this.runtimePayload()
|
|
388
451
|
});
|
|
389
452
|
}, HEARTBEAT_MS);
|
|
390
453
|
this.heartbeat.unref?.();
|
|
@@ -411,6 +474,29 @@ export class GatewayDeviceChannel {
|
|
|
411
474
|
}
|
|
412
475
|
this.socket.send(encoded);
|
|
413
476
|
}
|
|
477
|
+
async sendFlushed(message) {
|
|
478
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
479
|
+
throw new Error('Gateway socket is not open');
|
|
480
|
+
const encoded = JSON.stringify(message);
|
|
481
|
+
if (Buffer.byteLength(encoded, 'utf8') > MAX_OUTBOUND_MESSAGE_BYTES) {
|
|
482
|
+
const error = new Error('Gateway device response exceeds the outbound message limit');
|
|
483
|
+
error.code = 'DEVICE_OUTPUT_TOO_LARGE';
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
if (this.protocolVersion === PROTOCOL_VERSION_V2) {
|
|
487
|
+
if (!this.secureSocket || this.secureSocket.destroyed)
|
|
488
|
+
throw new Error('Gateway v2 inner TLS socket is not open');
|
|
489
|
+
const secureSocket = this.secureSocket;
|
|
490
|
+
await new Promise((resolve, reject) => {
|
|
491
|
+
secureSocket.write(encodeJsonFrame(message), error => error ? reject(error) : resolve());
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const socket = this.socket;
|
|
496
|
+
await new Promise((resolve, reject) => {
|
|
497
|
+
socket.send(encoded, error => error ? reject(error) : resolve());
|
|
498
|
+
});
|
|
499
|
+
}
|
|
414
500
|
fail(error) {
|
|
415
501
|
const reject = this.readyReject;
|
|
416
502
|
this.readyResolve = undefined;
|
|
@@ -13,6 +13,7 @@ export declare class GatewayDeviceIdentity {
|
|
|
13
13
|
});
|
|
14
14
|
loadOrCreate(): Promise<GatewayIdentityRecord>;
|
|
15
15
|
markEnrolled(): Promise<void>;
|
|
16
|
+
forget(): Promise<void>;
|
|
16
17
|
signChallenge(nonce: string): Promise<string>;
|
|
17
18
|
signChallengeV2(options: {
|
|
18
19
|
mode: 'reconnect' | 'enroll' | 'pair';
|
|
@@ -82,6 +82,10 @@ export class GatewayDeviceIdentity {
|
|
|
82
82
|
this.record = { ...current, enrolled: true };
|
|
83
83
|
await this.persist();
|
|
84
84
|
}
|
|
85
|
+
async forget() {
|
|
86
|
+
this.record = undefined;
|
|
87
|
+
await fs.rm(this.identityPath, { force: true });
|
|
88
|
+
}
|
|
85
89
|
async signChallenge(nonce) {
|
|
86
90
|
const current = await this.loadOrCreate();
|
|
87
91
|
const challenge = Buffer.from(`mcp-device-auth-v1\n${current.deviceId}\n${nonce}`, 'utf8');
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { LocalExecutionEngine } from './execution-engine.js';
|
|
2
2
|
export declare const GATEWAY_CAPABILITIES: readonly ["read_text_file", "write_file", "edit_file", "shell_execute", "start_process", "read_process_output", "interact_with_process", "terminate_process", "image_preview", "project_inspect"];
|
|
3
3
|
export interface GatewayToolAdapterOptions {
|
|
4
4
|
allowedRoots?: string[];
|
|
5
5
|
pathValidator?: (requestedPath: string) => Promise<string>;
|
|
6
6
|
}
|
|
7
7
|
export declare class GatewayToolAdapter {
|
|
8
|
-
private
|
|
8
|
+
private engine;
|
|
9
9
|
private allowedRoots;
|
|
10
10
|
private pathValidator;
|
|
11
11
|
private canonicalRoots?;
|
|
12
|
-
constructor(
|
|
12
|
+
constructor(engine: LocalExecutionEngine, options?: GatewayToolAdapterOptions);
|
|
13
13
|
private guardPath;
|
|
14
14
|
private imagePreview;
|
|
15
15
|
call(tool: string, args?: any): Promise<any>;
|