@wonderwhy-er/desktop-commander 0.2.29-alpha.0 → 0.2.29-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/dist/index.js +10 -0
- package/dist/npm-scripts/remote.d.ts +1 -0
- package/dist/npm-scripts/remote.js +6 -0
- package/dist/remote-device/desktop-commander-integration.d.ts +143 -0
- package/dist/remote-device/desktop-commander-integration.js +136 -0
- package/dist/remote-device/device-authenticator.d.ts +13 -0
- package/dist/remote-device/device-authenticator.js +160 -0
- package/dist/remote-device/device.d.ts +25 -0
- package/dist/remote-device/device.js +275 -0
- package/dist/remote-device/remote-channel.d.ts +48 -0
- package/dist/remote-device/remote-channel.js +198 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -3
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { runSetup } from './npm-scripts/setup.js';
|
|
|
7
7
|
import { runUninstall } from './npm-scripts/uninstall.js';
|
|
8
8
|
import { capture } from './utils/capture.js';
|
|
9
9
|
import { logToStderr, logger } from './utils/logger.js';
|
|
10
|
+
import { runRemote } from './npm-scripts/remote.js';
|
|
10
11
|
// Store messages to defer until after initialization
|
|
11
12
|
const deferredMessages = [];
|
|
12
13
|
function deferLog(level, message) {
|
|
@@ -24,6 +25,15 @@ async function runServer() {
|
|
|
24
25
|
await runUninstall();
|
|
25
26
|
return;
|
|
26
27
|
}
|
|
28
|
+
if (process.argv[2] === 'remote') {
|
|
29
|
+
await runRemote();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// Check if first argument is "remote"
|
|
33
|
+
if (process.argv[2] === 'remote') {
|
|
34
|
+
await runRemote();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
27
37
|
// Parse command line arguments for onboarding control
|
|
28
38
|
const DISABLE_ONBOARDING = process.argv.includes('--no-onboarding');
|
|
29
39
|
if (DISABLE_ONBOARDING) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runRemote(): Promise<void>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
interface McpConfig {
|
|
2
|
+
command: string;
|
|
3
|
+
args: string[];
|
|
4
|
+
cwd?: string;
|
|
5
|
+
env?: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
export declare class DesktopCommanderIntegration {
|
|
8
|
+
private mcpClient;
|
|
9
|
+
private mcpTransport;
|
|
10
|
+
private isReady;
|
|
11
|
+
initialize(): Promise<void>;
|
|
12
|
+
resolveMcpConfig(): Promise<McpConfig | null>;
|
|
13
|
+
executeTool(toolName: string, args: any): Promise<{
|
|
14
|
+
[x: string]: unknown;
|
|
15
|
+
content: ({
|
|
16
|
+
type: "text";
|
|
17
|
+
text: string;
|
|
18
|
+
annotations?: {
|
|
19
|
+
audience?: ("user" | "assistant")[] | undefined;
|
|
20
|
+
priority?: number | undefined;
|
|
21
|
+
lastModified?: string | undefined;
|
|
22
|
+
} | undefined;
|
|
23
|
+
_meta?: Record<string, unknown> | undefined;
|
|
24
|
+
} | {
|
|
25
|
+
type: "image";
|
|
26
|
+
data: string;
|
|
27
|
+
mimeType: string;
|
|
28
|
+
annotations?: {
|
|
29
|
+
audience?: ("user" | "assistant")[] | undefined;
|
|
30
|
+
priority?: number | undefined;
|
|
31
|
+
lastModified?: string | undefined;
|
|
32
|
+
} | undefined;
|
|
33
|
+
_meta?: Record<string, unknown> | undefined;
|
|
34
|
+
} | {
|
|
35
|
+
type: "audio";
|
|
36
|
+
data: string;
|
|
37
|
+
mimeType: string;
|
|
38
|
+
annotations?: {
|
|
39
|
+
audience?: ("user" | "assistant")[] | undefined;
|
|
40
|
+
priority?: number | undefined;
|
|
41
|
+
lastModified?: string | undefined;
|
|
42
|
+
} | undefined;
|
|
43
|
+
_meta?: Record<string, unknown> | undefined;
|
|
44
|
+
} | {
|
|
45
|
+
type: "resource";
|
|
46
|
+
resource: {
|
|
47
|
+
uri: string;
|
|
48
|
+
text: string;
|
|
49
|
+
mimeType?: string | undefined;
|
|
50
|
+
_meta?: Record<string, unknown> | undefined;
|
|
51
|
+
} | {
|
|
52
|
+
uri: string;
|
|
53
|
+
blob: string;
|
|
54
|
+
mimeType?: string | undefined;
|
|
55
|
+
_meta?: Record<string, unknown> | undefined;
|
|
56
|
+
};
|
|
57
|
+
annotations?: {
|
|
58
|
+
audience?: ("user" | "assistant")[] | undefined;
|
|
59
|
+
priority?: number | undefined;
|
|
60
|
+
lastModified?: string | undefined;
|
|
61
|
+
} | undefined;
|
|
62
|
+
_meta?: Record<string, unknown> | undefined;
|
|
63
|
+
} | {
|
|
64
|
+
uri: string;
|
|
65
|
+
name: string;
|
|
66
|
+
type: "resource_link";
|
|
67
|
+
description?: string | undefined;
|
|
68
|
+
mimeType?: string | undefined;
|
|
69
|
+
annotations?: {
|
|
70
|
+
audience?: ("user" | "assistant")[] | undefined;
|
|
71
|
+
priority?: number | undefined;
|
|
72
|
+
lastModified?: string | undefined;
|
|
73
|
+
} | undefined;
|
|
74
|
+
_meta?: {
|
|
75
|
+
[x: string]: unknown;
|
|
76
|
+
} | undefined;
|
|
77
|
+
icons?: {
|
|
78
|
+
src: string;
|
|
79
|
+
mimeType?: string | undefined;
|
|
80
|
+
sizes?: string[] | undefined;
|
|
81
|
+
theme?: "light" | "dark" | undefined;
|
|
82
|
+
}[] | undefined;
|
|
83
|
+
title?: string | undefined;
|
|
84
|
+
})[];
|
|
85
|
+
_meta?: {
|
|
86
|
+
[x: string]: unknown;
|
|
87
|
+
progressToken?: string | number | undefined;
|
|
88
|
+
"io.modelcontextprotocol/related-task"?: {
|
|
89
|
+
taskId: string;
|
|
90
|
+
} | undefined;
|
|
91
|
+
} | undefined;
|
|
92
|
+
structuredContent?: Record<string, unknown> | undefined;
|
|
93
|
+
isError?: boolean | undefined;
|
|
94
|
+
} | {
|
|
95
|
+
[x: string]: unknown;
|
|
96
|
+
toolResult: unknown;
|
|
97
|
+
_meta?: {
|
|
98
|
+
[x: string]: unknown;
|
|
99
|
+
progressToken?: string | number | undefined;
|
|
100
|
+
"io.modelcontextprotocol/related-task"?: {
|
|
101
|
+
taskId: string;
|
|
102
|
+
} | undefined;
|
|
103
|
+
} | undefined;
|
|
104
|
+
}>;
|
|
105
|
+
getCapabilities(): Promise<{
|
|
106
|
+
tools: {
|
|
107
|
+
inputSchema: {
|
|
108
|
+
[x: string]: unknown;
|
|
109
|
+
type: "object";
|
|
110
|
+
properties?: Record<string, object> | undefined;
|
|
111
|
+
required?: string[] | undefined;
|
|
112
|
+
};
|
|
113
|
+
name: string;
|
|
114
|
+
description?: string | undefined;
|
|
115
|
+
outputSchema?: {
|
|
116
|
+
[x: string]: unknown;
|
|
117
|
+
type: "object";
|
|
118
|
+
properties?: Record<string, object> | undefined;
|
|
119
|
+
required?: string[] | undefined;
|
|
120
|
+
} | undefined;
|
|
121
|
+
annotations?: {
|
|
122
|
+
title?: string | undefined;
|
|
123
|
+
readOnlyHint?: boolean | undefined;
|
|
124
|
+
destructiveHint?: boolean | undefined;
|
|
125
|
+
idempotentHint?: boolean | undefined;
|
|
126
|
+
openWorldHint?: boolean | undefined;
|
|
127
|
+
} | undefined;
|
|
128
|
+
execution?: {
|
|
129
|
+
taskSupport?: "optional" | "required" | "forbidden" | undefined;
|
|
130
|
+
} | undefined;
|
|
131
|
+
_meta?: Record<string, unknown> | undefined;
|
|
132
|
+
icons?: {
|
|
133
|
+
src: string;
|
|
134
|
+
mimeType?: string | undefined;
|
|
135
|
+
sizes?: string[] | undefined;
|
|
136
|
+
theme?: "light" | "dark" | undefined;
|
|
137
|
+
}[] | undefined;
|
|
138
|
+
title?: string | undefined;
|
|
139
|
+
}[];
|
|
140
|
+
}>;
|
|
141
|
+
shutdown(): Promise<void>;
|
|
142
|
+
}
|
|
143
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
export class DesktopCommanderIntegration {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.mcpClient = null;
|
|
12
|
+
this.mcpTransport = null;
|
|
13
|
+
this.isReady = false;
|
|
14
|
+
}
|
|
15
|
+
async initialize() {
|
|
16
|
+
const config = await this.resolveMcpConfig();
|
|
17
|
+
if (!config) {
|
|
18
|
+
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.');
|
|
19
|
+
}
|
|
20
|
+
console.log(` - ⏳ Connecting to Local Desktop Commander MCP using: ${config.command} ${config.args.join(' ')}`);
|
|
21
|
+
try {
|
|
22
|
+
this.mcpTransport = new StdioClientTransport(config);
|
|
23
|
+
// Create MCP client
|
|
24
|
+
this.mcpClient = new Client({
|
|
25
|
+
name: "desktop-commander-client",
|
|
26
|
+
version: "1.0.0"
|
|
27
|
+
}, {
|
|
28
|
+
capabilities: {}
|
|
29
|
+
});
|
|
30
|
+
// Connect to Desktop Commander
|
|
31
|
+
await this.mcpClient.connect(this.mcpTransport);
|
|
32
|
+
this.isReady = true;
|
|
33
|
+
console.log(' - 🔌 Connected to Desktop Commander MCP');
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error(' - ❌ Failed to connect to Desktop Commander MCP:', error);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async resolveMcpConfig() {
|
|
41
|
+
// Option 1: Development/Local Build
|
|
42
|
+
// Adjusting path resolution since we are now in src/remote-device and dist is in root/dist
|
|
43
|
+
// Original: path.resolve(__dirname, '../../dist/index.js')
|
|
44
|
+
const devPath = path.resolve(__dirname, '../../dist/index.js');
|
|
45
|
+
try {
|
|
46
|
+
await fs.access(devPath);
|
|
47
|
+
console.debug(' - 🔍 Found local MCP server at:', devPath);
|
|
48
|
+
return {
|
|
49
|
+
command: process.execPath, // Use the current node executable
|
|
50
|
+
args: [devPath],
|
|
51
|
+
cwd: path.dirname(devPath)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Local file not found, continue...
|
|
56
|
+
}
|
|
57
|
+
// Option 2: Global Installation
|
|
58
|
+
const commandName = 'desktop-commander';
|
|
59
|
+
try {
|
|
60
|
+
await new Promise((resolve, reject) => {
|
|
61
|
+
// Use 'which' to check if the command exists in PATH
|
|
62
|
+
// We can't run it directly as it's an stdio MCP server that waits for input
|
|
63
|
+
const check = spawn('which', [commandName]);
|
|
64
|
+
check.on('error', reject);
|
|
65
|
+
check.on('close', (code) => code === 0 ? resolve() : reject(new Error('Command not found')));
|
|
66
|
+
});
|
|
67
|
+
console.debug(' - Found global desktop-commander CLI');
|
|
68
|
+
return {
|
|
69
|
+
command: commandName,
|
|
70
|
+
args: []
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Global command not found
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
async executeTool(toolName, args) {
|
|
79
|
+
if (!this.isReady || !this.mcpClient) {
|
|
80
|
+
throw new Error('DesktopIntegration not initialized');
|
|
81
|
+
}
|
|
82
|
+
// Proxy other tools to MCP server
|
|
83
|
+
try {
|
|
84
|
+
console.log(`Forwarding tool call ${toolName} to MCP server`);
|
|
85
|
+
const result = await this.mcpClient.callTool({
|
|
86
|
+
name: toolName,
|
|
87
|
+
arguments: args
|
|
88
|
+
});
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
console.error(`Error executing tool ${toolName}:`, error);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async getCapabilities() {
|
|
97
|
+
if (!this.mcpClient)
|
|
98
|
+
return { tools: [] };
|
|
99
|
+
try {
|
|
100
|
+
// List tools from MCP server
|
|
101
|
+
const mcpTools = await this.mcpClient.listTools();
|
|
102
|
+
// Merge tools
|
|
103
|
+
return {
|
|
104
|
+
tools: mcpTools.tools || []
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
console.error('Error fetching capabilities:', error);
|
|
109
|
+
// Fallback to local tools
|
|
110
|
+
return {
|
|
111
|
+
tools: []
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async shutdown() {
|
|
116
|
+
if (this.mcpClient) {
|
|
117
|
+
try {
|
|
118
|
+
await this.mcpClient.close();
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
console.error('Error closing MCP client:', e);
|
|
122
|
+
}
|
|
123
|
+
this.mcpClient = null;
|
|
124
|
+
}
|
|
125
|
+
if (this.mcpTransport) {
|
|
126
|
+
try {
|
|
127
|
+
await this.mcpTransport.close();
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
console.error('Error closing MCP transport:', e);
|
|
131
|
+
}
|
|
132
|
+
this.mcpTransport = null;
|
|
133
|
+
}
|
|
134
|
+
this.isReady = false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
interface AuthSession {
|
|
2
|
+
access_token: string;
|
|
3
|
+
refresh_token: string | null;
|
|
4
|
+
}
|
|
5
|
+
export declare class DeviceAuthenticator {
|
|
6
|
+
private baseServerUrl;
|
|
7
|
+
constructor(baseServerUrl: string);
|
|
8
|
+
authenticate(): Promise<AuthSession>;
|
|
9
|
+
private isDesktopEnvironment;
|
|
10
|
+
private authenticateDesktop;
|
|
11
|
+
private authenticateHeadless;
|
|
12
|
+
}
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import readline from 'readline';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
function escapeHtml(text) {
|
|
11
|
+
if (text === null || text === undefined)
|
|
12
|
+
return '';
|
|
13
|
+
return String(text)
|
|
14
|
+
.replace(/&/g, "&")
|
|
15
|
+
.replace(/</g, "<")
|
|
16
|
+
.replace(/>/g, ">")
|
|
17
|
+
.replace(/"/g, """)
|
|
18
|
+
.replace(/'/g, "'");
|
|
19
|
+
}
|
|
20
|
+
const CALLBACK_PORT = 8121;
|
|
21
|
+
export class DeviceAuthenticator {
|
|
22
|
+
constructor(baseServerUrl) {
|
|
23
|
+
this.baseServerUrl = baseServerUrl;
|
|
24
|
+
}
|
|
25
|
+
async authenticate() {
|
|
26
|
+
// Detect environment
|
|
27
|
+
const isDesktop = this.isDesktopEnvironment();
|
|
28
|
+
console.log(`🔐 Starting authentication (${isDesktop ? 'desktop' : 'headless'} mode)...`);
|
|
29
|
+
if (isDesktop) {
|
|
30
|
+
return this.authenticateDesktop();
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
return this.authenticateHeadless();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
isDesktopEnvironment() {
|
|
37
|
+
// Check if we're in a desktop environment
|
|
38
|
+
return process.platform === 'darwin' ||
|
|
39
|
+
process.platform === 'win32' ||
|
|
40
|
+
(process.platform === 'linux' && !!process.env.DISPLAY);
|
|
41
|
+
}
|
|
42
|
+
async authenticateDesktop() {
|
|
43
|
+
const app = express();
|
|
44
|
+
const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
let server;
|
|
47
|
+
// Setup callback handler
|
|
48
|
+
app.get('/callback', (req, res) => {
|
|
49
|
+
const { access_token, refresh_token, code, error, error_description } = req.query;
|
|
50
|
+
// Extract the actual token (could be in access_token or code parameter)
|
|
51
|
+
const token = access_token || code;
|
|
52
|
+
if (error) {
|
|
53
|
+
const safeError = escapeHtml(error);
|
|
54
|
+
const safeErrorDesc = escapeHtml(error_description || 'Unknown error');
|
|
55
|
+
res.send(`
|
|
56
|
+
<h2>Authentication Failed</h2>
|
|
57
|
+
<p>Error: ${safeError}</p>
|
|
58
|
+
<p>Description: ${safeErrorDesc}</p>
|
|
59
|
+
<p>You can close this window.</p>
|
|
60
|
+
`);
|
|
61
|
+
server.close();
|
|
62
|
+
reject(new Error(`${error}: ${error_description}`));
|
|
63
|
+
}
|
|
64
|
+
else if (token) {
|
|
65
|
+
const templatePath = path.join(__dirname, 'templates', 'auth-success.html');
|
|
66
|
+
const htmlContent = fs.readFileSync(templatePath, 'utf8');
|
|
67
|
+
res.send(htmlContent);
|
|
68
|
+
server.close();
|
|
69
|
+
console.log(' - ✅ Authentication successful, token received');
|
|
70
|
+
resolve({
|
|
71
|
+
access_token: token,
|
|
72
|
+
refresh_token: refresh_token || null
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
console.log('❌ No token found in callback:', req.query);
|
|
77
|
+
const safeParams = escapeHtml(Object.keys(req.query).join(', '));
|
|
78
|
+
res.send(`
|
|
79
|
+
<h2>Authentication Failed</h2>
|
|
80
|
+
<p>No access token received</p>
|
|
81
|
+
<p>Received parameters: ${safeParams}</p>
|
|
82
|
+
<p>You can close this window.</p>
|
|
83
|
+
`);
|
|
84
|
+
server.close();
|
|
85
|
+
reject(new Error('No access token received'));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
// Start callback server
|
|
89
|
+
server = createServer(app);
|
|
90
|
+
server.listen(CALLBACK_PORT, () => {
|
|
91
|
+
const authUrl = `${this.baseServerUrl}/?redirect_uri=${encodeURIComponent(callbackUrl)}&device=true`;
|
|
92
|
+
console.log(' - 🌐 Opening browser for authentication...');
|
|
93
|
+
console.log(` - If browser doesn't open, visit: ${authUrl}`);
|
|
94
|
+
// Open browser
|
|
95
|
+
open(authUrl).catch(() => {
|
|
96
|
+
console.log(' - Could not open browser automatically.');
|
|
97
|
+
console.log(` - Please visit: ${authUrl}`);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
server.on('error', (err) => {
|
|
101
|
+
reject(new Error(`Failed to start callback server: ${err.message}`));
|
|
102
|
+
});
|
|
103
|
+
// Timeout after 5 minutes
|
|
104
|
+
setTimeout(() => {
|
|
105
|
+
if (server.listening) {
|
|
106
|
+
server.close();
|
|
107
|
+
reject(new Error(' - Authentication timeout - no response received'));
|
|
108
|
+
}
|
|
109
|
+
}, 5 * 60 * 1000);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async authenticateHeadless() {
|
|
113
|
+
console.log('\n🔗 Manual Authentication Required:');
|
|
114
|
+
console.log('─'.repeat(50));
|
|
115
|
+
console.log(`1. Open this URL in a browser: ${this.baseServerUrl}/`);
|
|
116
|
+
console.log('2. Complete the authentication process');
|
|
117
|
+
console.log('3. You will be redirected to a URL with parameters.');
|
|
118
|
+
console.log(' If using device mode, look for access_token and refresh_token.');
|
|
119
|
+
console.log('4. Copy the access_token (and refresh_token if available) and paste here.');
|
|
120
|
+
console.log(' Format: access_token OR {"access_token":"...", "refresh_token":"..."}');
|
|
121
|
+
console.log('─'.repeat(50));
|
|
122
|
+
const rl = readline.createInterface({
|
|
123
|
+
input: process.stdin,
|
|
124
|
+
output: process.stdout
|
|
125
|
+
});
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
rl.question('\n🔑 Enter Access Token or JSON: ', (input) => {
|
|
128
|
+
rl.close();
|
|
129
|
+
const trimmedInput = input.trim();
|
|
130
|
+
if (!trimmedInput) {
|
|
131
|
+
reject(new Error('Empty input provided'));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
// Try parsing as JSON first
|
|
136
|
+
const json = JSON.parse(trimmedInput);
|
|
137
|
+
if (json.access_token) {
|
|
138
|
+
resolve({
|
|
139
|
+
access_token: json.access_token,
|
|
140
|
+
refresh_token: json.refresh_token || null
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
// Not JSON, treat as raw token
|
|
147
|
+
}
|
|
148
|
+
if (trimmedInput.length < 10) {
|
|
149
|
+
reject(new Error('Invalid token format (too short)'));
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
resolve({
|
|
153
|
+
access_token: trimmedInput,
|
|
154
|
+
refresh_token: null
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export interface MCPDeviceOptions {
|
|
3
|
+
persistSession?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare class MCPDevice {
|
|
6
|
+
private baseServerUrl;
|
|
7
|
+
private remoteChannel;
|
|
8
|
+
private deviceId;
|
|
9
|
+
private user;
|
|
10
|
+
private isShuttingDown;
|
|
11
|
+
private configPath;
|
|
12
|
+
private persistSession;
|
|
13
|
+
private desktop;
|
|
14
|
+
constructor(options?: MCPDeviceOptions);
|
|
15
|
+
private setupShutdownHandlers;
|
|
16
|
+
start(): Promise<void>;
|
|
17
|
+
loadPersistedConfig(): Promise<any>;
|
|
18
|
+
savePersistedConfig(session: any): Promise<void>;
|
|
19
|
+
fetchSupabaseConfig(): Promise<{
|
|
20
|
+
supabaseUrl: any;
|
|
21
|
+
anonKey: any;
|
|
22
|
+
}>;
|
|
23
|
+
handleNewToolCall(payload: any): Promise<void>;
|
|
24
|
+
shutdown(): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { RemoteChannel } from './remote-channel.js';
|
|
3
|
+
import { DeviceAuthenticator } from './device-authenticator.js';
|
|
4
|
+
import { DesktopCommanderIntegration } from './desktop-commander-integration.js';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
export class MCPDevice {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.baseServerUrl = process.env.MCP_SERVER_URL || 'https://mcp.desktopcommander.app';
|
|
12
|
+
this.remoteChannel = new RemoteChannel();
|
|
13
|
+
this.deviceId = null;
|
|
14
|
+
this.user = null;
|
|
15
|
+
this.isShuttingDown = false;
|
|
16
|
+
this.configPath = path.join(os.homedir(), '.desktop-commander-device', 'device.json');
|
|
17
|
+
this.persistSession = options.persistSession || false;
|
|
18
|
+
// Initialize desktop integration
|
|
19
|
+
this.desktop = new DesktopCommanderIntegration();
|
|
20
|
+
// Graceful shutdown handlers (only set once)
|
|
21
|
+
this.setupShutdownHandlers();
|
|
22
|
+
}
|
|
23
|
+
setupShutdownHandlers() {
|
|
24
|
+
const handleShutdown = async (signal) => {
|
|
25
|
+
if (this.isShuttingDown) {
|
|
26
|
+
console.log(`\n${signal} received, but already shutting down...`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
this.isShuttingDown = true;
|
|
30
|
+
console.log(`\n${signal} received, initiating graceful shutdown...`);
|
|
31
|
+
// Force exit after 3 seconds if graceful shutdown hangs
|
|
32
|
+
const forceExit = setTimeout(() => {
|
|
33
|
+
console.error('⚠️ Graceful shutdown timed out, forcing exit...');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}, 3000);
|
|
36
|
+
try {
|
|
37
|
+
await this.shutdown();
|
|
38
|
+
clearTimeout(forceExit);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
console.error('Error during shutdown:', error);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
process.on('SIGINT', () => {
|
|
47
|
+
handleShutdown('SIGINT');
|
|
48
|
+
});
|
|
49
|
+
process.on('SIGTERM', () => {
|
|
50
|
+
handleShutdown('SIGTERM');
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async start() {
|
|
54
|
+
try {
|
|
55
|
+
console.log('🚀 Starting MCP Device...');
|
|
56
|
+
if (process.env.DEBUG_MODE === 'true') {
|
|
57
|
+
console.log(` - 🐞 DEBUG_MODE`);
|
|
58
|
+
}
|
|
59
|
+
// Initialize desktop integration
|
|
60
|
+
await this.desktop.initialize();
|
|
61
|
+
console.log(`⏳ Connecting to Remote MCP ${this.baseServerUrl}`);
|
|
62
|
+
const { supabaseUrl, anonKey } = await this.fetchSupabaseConfig();
|
|
63
|
+
console.log(` - 🔌 Connected to Remote MCP`);
|
|
64
|
+
// Initialize Remote Channel
|
|
65
|
+
this.remoteChannel.initialize(supabaseUrl, anonKey);
|
|
66
|
+
// Load persisted configuration (deviceId, session)
|
|
67
|
+
let session = await this.loadPersistedConfig();
|
|
68
|
+
// 2. Set Session or Authenticate
|
|
69
|
+
if (session) {
|
|
70
|
+
const { error } = await this.remoteChannel.setSession(session);
|
|
71
|
+
if (error) {
|
|
72
|
+
console.log(' - ⚠️ Persisted session invalid:', error.message);
|
|
73
|
+
session = null;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
console.log(' - ✅ Session restored');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!session) {
|
|
80
|
+
console.log('\n🔐 Authenticating with Remote MCP server...');
|
|
81
|
+
const authenticator = new DeviceAuthenticator(this.baseServerUrl);
|
|
82
|
+
session = await authenticator.authenticate();
|
|
83
|
+
// Set session in Remote Channel
|
|
84
|
+
const { error } = await this.remoteChannel.setSession(session);
|
|
85
|
+
if (error)
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
// 3. Setup Token Refresh Listener
|
|
89
|
+
this.remoteChannel.onAuthStateChange(async (event, newSession) => {
|
|
90
|
+
const eventMap = {
|
|
91
|
+
'SIGNED_IN': '🔑 User signed in',
|
|
92
|
+
'TOKEN_REFRESHED': '🔄 Token refreshed',
|
|
93
|
+
'SIGNED_OUT': '⚠️ User signed out',
|
|
94
|
+
};
|
|
95
|
+
if (eventMap[event]) {
|
|
96
|
+
console.log(eventMap[event]);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
// Force save the current session immediately to ensure it's persisted
|
|
100
|
+
const currentSessionStore = await this.remoteChannel.getSession();
|
|
101
|
+
await this.savePersistedConfig(currentSessionStore.data.session);
|
|
102
|
+
// Get user info
|
|
103
|
+
const { data: { user }, error: userError } = await this.remoteChannel.getUser();
|
|
104
|
+
if (userError)
|
|
105
|
+
throw userError;
|
|
106
|
+
this.user = user;
|
|
107
|
+
const deviceName = os.hostname();
|
|
108
|
+
// Register as device
|
|
109
|
+
this.deviceId = await this.remoteChannel.registerDevice(this.user.id, await this.desktop.getCapabilities(), this.deviceId, deviceName);
|
|
110
|
+
// Also save session again just in case (optional, but harmless)
|
|
111
|
+
const { data: { session: currentSession } } = await this.remoteChannel.getSession();
|
|
112
|
+
await this.savePersistedConfig(currentSession);
|
|
113
|
+
// Subscribe to tool calls
|
|
114
|
+
await this.remoteChannel.subscribe(this.user.id, (payload) => this.handleNewToolCall(payload));
|
|
115
|
+
console.log('✅ Device ready:');
|
|
116
|
+
console.log(` - Device ID: ${this.deviceId}`);
|
|
117
|
+
console.log(` - Device Name: ${deviceName}`);
|
|
118
|
+
// Keep process alive
|
|
119
|
+
this.remoteChannel.startHeartbeat(this.deviceId);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
console.error(' - ❌ Device startup failed:', error.message);
|
|
123
|
+
if (error.stack && process.env.DEBUG_MODE === 'true') {
|
|
124
|
+
console.error('Stack trace:', error.stack);
|
|
125
|
+
}
|
|
126
|
+
await this.shutdown();
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async loadPersistedConfig() {
|
|
131
|
+
try {
|
|
132
|
+
const data = await fs.readFile(this.configPath, 'utf8');
|
|
133
|
+
const config = JSON.parse(data);
|
|
134
|
+
this.deviceId = config?.deviceId;
|
|
135
|
+
console.log('💾 Found persisted session for device ' + this.deviceId);
|
|
136
|
+
if (config.session) {
|
|
137
|
+
return config.session;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error.code !== 'ENOENT') {
|
|
143
|
+
console.warn('⚠️ Failed to load config:', error.message);
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
// No need to ensure device ID here
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async savePersistedConfig(session) {
|
|
152
|
+
try {
|
|
153
|
+
const config = {
|
|
154
|
+
deviceId: this.deviceId,
|
|
155
|
+
// Only save session if --persist-session flag is set
|
|
156
|
+
session: (session && this.persistSession) ? {
|
|
157
|
+
access_token: session.access_token,
|
|
158
|
+
refresh_token: session.refresh_token
|
|
159
|
+
} : null
|
|
160
|
+
};
|
|
161
|
+
// Ensure the config directory exists
|
|
162
|
+
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
|
|
163
|
+
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
164
|
+
// if (session) console.debug('💾 Session saved to device.json');
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
console.error(' - ❌ Failed to save config:', error.message);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async fetchSupabaseConfig() {
|
|
171
|
+
// No auth header needed for this public endpoint
|
|
172
|
+
const response = await fetch(`${this.baseServerUrl}/api/mcp-info`);
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new Error(`Failed to fetch Supabase config: ${response.statusText}`);
|
|
175
|
+
}
|
|
176
|
+
const config = await response.json();
|
|
177
|
+
return {
|
|
178
|
+
supabaseUrl: config.supabaseUrl,
|
|
179
|
+
anonKey: config.supabaseAnonKey
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// Methods moved to RemoteChannel
|
|
183
|
+
async handleNewToolCall(payload) {
|
|
184
|
+
const toolCall = payload.new;
|
|
185
|
+
// Assuming database also renames agent_id to device_id, but user only said rename agent -> device everywhere but only inside src/remote-device
|
|
186
|
+
// If the database column is still agent_id, we need a mapping.
|
|
187
|
+
// However, the user said "literally all agent should be renamed to device everywhere", so we assume DB column is device_id.
|
|
188
|
+
const { id: call_id, tool_name, tool_args, device_id } = toolCall;
|
|
189
|
+
// Only process jobs for this device
|
|
190
|
+
if (device_id && device_id !== this.deviceId) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
console.log(`🔧 Received tool call ${call_id}: ${tool_name} ${JSON.stringify(tool_args)}`);
|
|
194
|
+
try {
|
|
195
|
+
// Update call status to executing
|
|
196
|
+
await this.remoteChannel.markCallExecuting(call_id);
|
|
197
|
+
let result;
|
|
198
|
+
// Handle 'ping' tool specially
|
|
199
|
+
if (tool_name === 'ping') {
|
|
200
|
+
result = {
|
|
201
|
+
content: [{
|
|
202
|
+
type: 'text',
|
|
203
|
+
text: `pong ${new Date().toISOString()}`
|
|
204
|
+
}]
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
else if (tool_name === 'shutdown') {
|
|
208
|
+
result = {
|
|
209
|
+
content: [{
|
|
210
|
+
type: 'text',
|
|
211
|
+
text: `Shutdown initialized at ${new Date().toISOString()}`
|
|
212
|
+
}]
|
|
213
|
+
};
|
|
214
|
+
// Trigger shutdown after sending response
|
|
215
|
+
setTimeout(async () => {
|
|
216
|
+
console.log('🛑 Remote shutdown requested. Exiting...');
|
|
217
|
+
await this.shutdown();
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}, 1000);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// Execute other tools using desktop integration
|
|
223
|
+
result = await this.desktop.executeTool(tool_name, tool_args);
|
|
224
|
+
}
|
|
225
|
+
console.log(`✅ Tool call ${tool_name} completed:\r\n ${JSON.stringify(result)}`);
|
|
226
|
+
// Update database with result
|
|
227
|
+
await this.remoteChannel.updateCallResult(call_id, 'completed', result);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
console.error(`❌ Tool call ${tool_name} failed:`, error.message);
|
|
231
|
+
await this.remoteChannel.updateCallResult(call_id, 'failed', null, error.message);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Moved to RemoteChannel
|
|
235
|
+
// Moved to RemoteChannel
|
|
236
|
+
async shutdown() {
|
|
237
|
+
if (this.isShuttingDown) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this.isShuttingDown = true;
|
|
241
|
+
console.log('\n🛑 Shutting down device...');
|
|
242
|
+
try {
|
|
243
|
+
// Remote shutdown
|
|
244
|
+
await this.remoteChannel.unsubscribe();
|
|
245
|
+
await this.remoteChannel.setOffline(this.deviceId);
|
|
246
|
+
// Shutdown desktop integration
|
|
247
|
+
await this.desktop.shutdown();
|
|
248
|
+
console.log('✓ Device shutdown complete');
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
console.error('Shutdown error:', error.message);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Start device if called directly or as a bin command
|
|
256
|
+
// When installed globally, npm creates a wrapper, so we need to check multiple conditions
|
|
257
|
+
const isMainModule = process.argv[1] && (
|
|
258
|
+
// Direct execution: node device.js
|
|
259
|
+
import.meta.url === `file://${process.argv[1]}` ||
|
|
260
|
+
fileURLToPath(import.meta.url) === process.argv[1] ||
|
|
261
|
+
// Global bin execution: desktop-commander-device (npm creates a wrapper)
|
|
262
|
+
process.argv[1].endsWith('desktop-commander-device') ||
|
|
263
|
+
process.argv[1].endsWith('desktop-commander-device.js'));
|
|
264
|
+
if (isMainModule) {
|
|
265
|
+
// Parse command-line arguments
|
|
266
|
+
const args = process.argv.slice(2);
|
|
267
|
+
const options = {
|
|
268
|
+
persistSession: args.includes('--persist-session')
|
|
269
|
+
};
|
|
270
|
+
if (options.persistSession) {
|
|
271
|
+
console.log('🔒 Session persistence enabled');
|
|
272
|
+
}
|
|
273
|
+
const device = new MCPDevice(options);
|
|
274
|
+
device.start();
|
|
275
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Session, UserResponse } from '@supabase/supabase-js';
|
|
2
|
+
export interface AuthSession {
|
|
3
|
+
access_token: string;
|
|
4
|
+
refresh_token: string | null;
|
|
5
|
+
}
|
|
6
|
+
interface DeviceData {
|
|
7
|
+
user_id: string;
|
|
8
|
+
device_name: string;
|
|
9
|
+
capabilities: any;
|
|
10
|
+
status: string;
|
|
11
|
+
last_seen: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class RemoteChannel {
|
|
14
|
+
private client;
|
|
15
|
+
private channel;
|
|
16
|
+
private heartbeatInterval;
|
|
17
|
+
initialize(url: string, key: string): void;
|
|
18
|
+
setSession(session: AuthSession): Promise<{
|
|
19
|
+
error: any;
|
|
20
|
+
}>;
|
|
21
|
+
getSession(): Promise<{
|
|
22
|
+
data: {
|
|
23
|
+
session: Session | null;
|
|
24
|
+
};
|
|
25
|
+
error: any;
|
|
26
|
+
}>;
|
|
27
|
+
getUser(): Promise<UserResponse>;
|
|
28
|
+
onAuthStateChange(callback: (event: string, session: Session | null) => void): {
|
|
29
|
+
data: {
|
|
30
|
+
subscription: import("@supabase/supabase-js").Subscription;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
findDevice(deviceId: string, userId: string): Promise<{
|
|
34
|
+
id: any;
|
|
35
|
+
device_name: any;
|
|
36
|
+
} | null>;
|
|
37
|
+
updateDevice(deviceId: string, updates: any): Promise<import("@supabase/postgrest-js").PostgrestSingleResponse<null>>;
|
|
38
|
+
createDevice(deviceData: DeviceData): Promise<import("@supabase/postgrest-js").PostgrestSingleResponse<any>>;
|
|
39
|
+
registerDevice(userId: string, capabilities: any, currentDeviceId: string | null, deviceName: string): Promise<string>;
|
|
40
|
+
subscribe(userId: string, onToolCall: (payload: any) => void): Promise<void>;
|
|
41
|
+
markCallExecuting(callId: string): Promise<void>;
|
|
42
|
+
updateCallResult(callId: string, status: string, result?: any, errorMessage?: string | null): Promise<void>;
|
|
43
|
+
updateHeartbeat(deviceId: string): Promise<void>;
|
|
44
|
+
startHeartbeat(deviceId: string): void;
|
|
45
|
+
setOffline(deviceId: string | null): Promise<void>;
|
|
46
|
+
unsubscribe(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
const HEARTBEAT_INTERVAL = 30000;
|
|
3
|
+
export class RemoteChannel {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.client = null;
|
|
6
|
+
this.channel = null;
|
|
7
|
+
this.heartbeatInterval = null;
|
|
8
|
+
}
|
|
9
|
+
initialize(url, key) {
|
|
10
|
+
this.client = createClient(url, key);
|
|
11
|
+
}
|
|
12
|
+
async setSession(session) {
|
|
13
|
+
if (!this.client)
|
|
14
|
+
throw new Error('Client not initialized');
|
|
15
|
+
const { error } = await this.client.auth.setSession({
|
|
16
|
+
access_token: session.access_token,
|
|
17
|
+
refresh_token: session.refresh_token || ''
|
|
18
|
+
});
|
|
19
|
+
return { error };
|
|
20
|
+
}
|
|
21
|
+
async getSession() {
|
|
22
|
+
if (!this.client)
|
|
23
|
+
throw new Error('Client not initialized');
|
|
24
|
+
return await this.client.auth.getSession();
|
|
25
|
+
}
|
|
26
|
+
async getUser() {
|
|
27
|
+
if (!this.client)
|
|
28
|
+
throw new Error('Client not initialized');
|
|
29
|
+
return await this.client.auth.getUser();
|
|
30
|
+
}
|
|
31
|
+
onAuthStateChange(callback) {
|
|
32
|
+
if (!this.client)
|
|
33
|
+
throw new Error('Client not initialized');
|
|
34
|
+
return this.client.auth.onAuthStateChange(callback);
|
|
35
|
+
}
|
|
36
|
+
async findDevice(deviceId, userId) {
|
|
37
|
+
if (!this.client)
|
|
38
|
+
throw new Error('Client not initialized');
|
|
39
|
+
const { data, error } = await this.client
|
|
40
|
+
.from('mcp_devices')
|
|
41
|
+
.select('id, device_name')
|
|
42
|
+
.eq('id', deviceId)
|
|
43
|
+
.eq('user_id', userId)
|
|
44
|
+
.maybeSingle();
|
|
45
|
+
if (error)
|
|
46
|
+
throw error;
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
async updateDevice(deviceId, updates) {
|
|
50
|
+
if (!this.client)
|
|
51
|
+
throw new Error('Client not initialized');
|
|
52
|
+
return await this.client
|
|
53
|
+
.from('mcp_devices')
|
|
54
|
+
.update(updates)
|
|
55
|
+
.eq('id', deviceId);
|
|
56
|
+
}
|
|
57
|
+
async createDevice(deviceData) {
|
|
58
|
+
if (!this.client)
|
|
59
|
+
throw new Error('Client not initialized');
|
|
60
|
+
return await this.client
|
|
61
|
+
.from('mcp_devices')
|
|
62
|
+
.insert(deviceData)
|
|
63
|
+
.select()
|
|
64
|
+
.single();
|
|
65
|
+
}
|
|
66
|
+
async registerDevice(userId, capabilities, currentDeviceId, deviceName) {
|
|
67
|
+
let existingDevice = null;
|
|
68
|
+
if (currentDeviceId) {
|
|
69
|
+
try {
|
|
70
|
+
existingDevice = await this.findDevice(currentDeviceId, userId);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
// ignore error, treat as not found
|
|
74
|
+
console.warn('Error checking existing device:', e.message);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (existingDevice) {
|
|
78
|
+
console.log(`🔍 Found existing device: ${existingDevice.device_name} (${existingDevice.id})`);
|
|
79
|
+
await this.updateDevice(existingDevice.id, {
|
|
80
|
+
status: 'online',
|
|
81
|
+
last_seen: new Date().toISOString(),
|
|
82
|
+
capabilities: capabilities,
|
|
83
|
+
device_name: deviceName
|
|
84
|
+
});
|
|
85
|
+
return existingDevice.id;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
if (currentDeviceId) {
|
|
89
|
+
console.log(` - ⚠️ persisted deviceId ${currentDeviceId} not found for user ${userId}. Creating new device...`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.log(' - 📝 No existing device found, creating new registration...');
|
|
93
|
+
}
|
|
94
|
+
const { data: newDevice, error } = await this.createDevice({
|
|
95
|
+
user_id: userId,
|
|
96
|
+
device_name: deviceName,
|
|
97
|
+
capabilities: capabilities,
|
|
98
|
+
status: 'online',
|
|
99
|
+
last_seen: new Date().toISOString()
|
|
100
|
+
});
|
|
101
|
+
if (error)
|
|
102
|
+
throw error;
|
|
103
|
+
console.log(` - ✅ Device registered: ${newDevice.device_name}`);
|
|
104
|
+
console.log(` - ✅ Assigned new Device ID: ${newDevice.id}`);
|
|
105
|
+
return newDevice.id;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async subscribe(userId, onToolCall) {
|
|
109
|
+
if (!this.client)
|
|
110
|
+
throw new Error('Client not initialized');
|
|
111
|
+
console.debug(` - ⏳ Subscribing to call queue...`);
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
if (!this.client)
|
|
114
|
+
return reject(new Error('Client not initialized'));
|
|
115
|
+
this.channel = this.client.channel('device_tool_call_queue')
|
|
116
|
+
.on('postgres_changes', {
|
|
117
|
+
event: 'INSERT',
|
|
118
|
+
schema: 'public',
|
|
119
|
+
table: 'mcp_remote_calls',
|
|
120
|
+
filter: `user_id=eq.${userId}`
|
|
121
|
+
}, (payload) => onToolCall(payload))
|
|
122
|
+
.subscribe((status, err) => {
|
|
123
|
+
if (status === 'SUBSCRIBED') {
|
|
124
|
+
console.debug(' - 🔌 Connected to call queue');
|
|
125
|
+
resolve();
|
|
126
|
+
}
|
|
127
|
+
else if (status === 'CHANNEL_ERROR') {
|
|
128
|
+
console.error(' - ❌ Failed to connect to call queue:', err);
|
|
129
|
+
reject(err || new Error('Failed to initialize call queue subscription'));
|
|
130
|
+
}
|
|
131
|
+
else if (status === 'TIMED_OUT') {
|
|
132
|
+
console.error(' - ❌ Connection to call queue timed out');
|
|
133
|
+
reject(new Error('Call queue subscription timed out'));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async markCallExecuting(callId) {
|
|
139
|
+
if (!this.client)
|
|
140
|
+
throw new Error('Client not initialized');
|
|
141
|
+
await this.client
|
|
142
|
+
.from('mcp_remote_calls')
|
|
143
|
+
.update({ status: 'executing' })
|
|
144
|
+
.eq('id', callId);
|
|
145
|
+
}
|
|
146
|
+
async updateCallResult(callId, status, result = null, errorMessage = null) {
|
|
147
|
+
if (!this.client)
|
|
148
|
+
throw new Error('Client not initialized');
|
|
149
|
+
const updateData = {
|
|
150
|
+
status: status,
|
|
151
|
+
completed_at: new Date().toISOString()
|
|
152
|
+
};
|
|
153
|
+
if (result !== null)
|
|
154
|
+
updateData.result = result;
|
|
155
|
+
if (errorMessage !== null)
|
|
156
|
+
updateData.error_message = errorMessage;
|
|
157
|
+
await this.client
|
|
158
|
+
.from('mcp_remote_calls')
|
|
159
|
+
.update(updateData)
|
|
160
|
+
.eq('id', callId);
|
|
161
|
+
}
|
|
162
|
+
async updateHeartbeat(deviceId) {
|
|
163
|
+
if (!this.client)
|
|
164
|
+
return;
|
|
165
|
+
try {
|
|
166
|
+
await this.client
|
|
167
|
+
.from('mcp_devices')
|
|
168
|
+
.update({ last_seen: new Date().toISOString() })
|
|
169
|
+
.eq('id', deviceId);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
console.error('Heartbeat failed:', error.message);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
startHeartbeat(deviceId) {
|
|
176
|
+
// Update last_seen every 30 seconds
|
|
177
|
+
this.heartbeatInterval = setInterval(async () => {
|
|
178
|
+
await this.updateHeartbeat(deviceId);
|
|
179
|
+
}, HEARTBEAT_INTERVAL);
|
|
180
|
+
}
|
|
181
|
+
async setOffline(deviceId) {
|
|
182
|
+
if (deviceId && this.client) {
|
|
183
|
+
await this.client
|
|
184
|
+
.from('mcp_devices')
|
|
185
|
+
.update({ status: 'offline' })
|
|
186
|
+
.eq('id', deviceId);
|
|
187
|
+
console.log('✓ Device marked as offline');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async unsubscribe() {
|
|
191
|
+
if (this.channel) {
|
|
192
|
+
if (this.heartbeatInterval)
|
|
193
|
+
clearInterval(this.heartbeatInterval);
|
|
194
|
+
await this.channel.unsubscribe();
|
|
195
|
+
console.log('✓ Unsubscribed from channel');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.2.29-alpha.
|
|
1
|
+
export declare const VERSION = "0.2.29-alpha.1";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.2.29-alpha.
|
|
1
|
+
export const VERSION = '0.2.29-alpha.1';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wonderwhy-er/desktop-commander",
|
|
3
|
-
"version": "0.2.29-alpha.
|
|
3
|
+
"version": "0.2.29-alpha.1",
|
|
4
4
|
"description": "MCP server for terminal operations and file editing",
|
|
5
5
|
"mcpName": "io.github.wonderwhy-er/desktop-commander",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"postinstall": "node dist/track-installation.js && node dist/npm-scripts/verify-ripgrep.js || node -e \"process.exit(0)\"",
|
|
26
26
|
"open-chat": "open -n /Applications/Claude.app",
|
|
27
27
|
"device:install": "cd src/remote-device && npm install",
|
|
28
|
-
"device:start": "
|
|
29
|
-
"device:dev": "
|
|
28
|
+
"device:start": "tsx src/remote-device/device.ts",
|
|
29
|
+
"device:start:dev": "nodemon --watch src/remote-device --exec tsx src/remote-device/device.ts",
|
|
30
30
|
"sync-version": "node scripts/sync-version.js",
|
|
31
31
|
"bump": "node scripts/sync-version.js --bump",
|
|
32
32
|
"bump:minor": "node scripts/sync-version.js --bump --minor",
|
|
@@ -83,14 +83,17 @@
|
|
|
83
83
|
"dependencies": {
|
|
84
84
|
"@modelcontextprotocol/sdk": "^1.9.0",
|
|
85
85
|
"@opendocsg/pdf2md": "^0.2.2",
|
|
86
|
+
"@supabase/supabase-js": "^2.89.0",
|
|
86
87
|
"@vscode/ripgrep": "^1.15.9",
|
|
87
88
|
"cross-fetch": "^4.1.0",
|
|
88
89
|
"exceljs": "^4.4.0",
|
|
90
|
+
"express": "^4.22.1",
|
|
89
91
|
"fastest-levenshtein": "^1.0.16",
|
|
90
92
|
"file-type": "^21.1.1",
|
|
91
93
|
"glob": "^10.3.10",
|
|
92
94
|
"isbinaryfile": "^5.0.4",
|
|
93
95
|
"md-to-pdf": "^5.2.5",
|
|
96
|
+
"open": "^10.2.0",
|
|
94
97
|
"pdf-lib": "^1.17.1",
|
|
95
98
|
"remark": "^15.0.1",
|
|
96
99
|
"remark-gfm": "^4.0.1",
|
|
@@ -103,11 +106,14 @@
|
|
|
103
106
|
},
|
|
104
107
|
"devDependencies": {
|
|
105
108
|
"@anthropic-ai/mcpb": "^1.2.0",
|
|
109
|
+
"@types/express": "^5.0.6",
|
|
106
110
|
"@types/node": "^20.17.24",
|
|
107
111
|
"commander": "^13.1.0",
|
|
108
112
|
"nexe": "^5.0.0-beta.4",
|
|
109
113
|
"nodemon": "^3.0.2",
|
|
110
114
|
"shx": "^0.3.4",
|
|
115
|
+
"ts-node": "^10.9.2",
|
|
116
|
+
"tsx": "^4.21.0",
|
|
111
117
|
"typescript": "^5.3.3"
|
|
112
118
|
}
|
|
113
119
|
}
|