mobilecoder-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/cli-adapter.d.ts +13 -0
- package/dist/adapters/cli-adapter.d.ts.map +1 -0
- package/dist/adapters/cli-adapter.js +62 -0
- package/dist/adapters/cli-adapter.js.map +1 -0
- package/dist/agent.d.ts +10 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +63 -0
- package/dist/agent.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +175 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-handler.d.ts +3 -0
- package/dist/mcp-handler.d.ts.map +1 -0
- package/dist/mcp-handler.js +317 -0
- package/dist/mcp-handler.js.map +1 -0
- package/dist/security.d.ts +52 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +307 -0
- package/dist/security.js.map +1 -0
- package/dist/tool-detector.d.ts +18 -0
- package/dist/tool-detector.d.ts.map +1 -0
- package/dist/tool-detector.js +130 -0
- package/dist/tool-detector.js.map +1 -0
- package/dist/webrtc.d.ts +20 -0
- package/dist/webrtc.d.ts.map +1 -0
- package/dist/webrtc.js +152 -0
- package/dist/webrtc.js.map +1 -0
- package/package.json +35 -0
- package/src/adapters/cli-adapter.ts +73 -0
- package/src/agent.ts +71 -0
- package/src/index.ts +162 -0
- package/src/mcp-handler.ts +324 -0
- package/src/security.ts +294 -0
- package/src/tool-detector.ts +110 -0
- package/src/webrtc.ts +156 -0
- package/tsconfig.json +21 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mobilecoder-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for MobileCoderMCP - enables mobile to desktop coding",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mobile-coder-mcp": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc --watch",
|
|
12
|
+
"start": "node dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"cursor",
|
|
17
|
+
"windsurf",
|
|
18
|
+
"mobile-coder"
|
|
19
|
+
],
|
|
20
|
+
"author": "",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
24
|
+
"commander": "^12.1.0",
|
|
25
|
+
"qrcode-terminal": "^0.12.0",
|
|
26
|
+
"simple-peer": "^9.11.1",
|
|
27
|
+
"wrtc": "^0.4.7"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^20.14.0",
|
|
31
|
+
"@types/qrcode-terminal": "^0.12.2",
|
|
32
|
+
"@types/simple-peer": "^9.11.8",
|
|
33
|
+
"typescript": "^5.5.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from 'child_process';
|
|
2
|
+
import { platform } from 'os';
|
|
3
|
+
|
|
4
|
+
export interface CLIResult {
|
|
5
|
+
output: string;
|
|
6
|
+
exitCode: number | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class CLIAdapter {
|
|
10
|
+
private process: ChildProcess | null = null;
|
|
11
|
+
private onOutputCallback?: (data: string) => void;
|
|
12
|
+
|
|
13
|
+
constructor() { }
|
|
14
|
+
|
|
15
|
+
execute(command: string, args: string[] = [], cwd: string = process.cwd()): void {
|
|
16
|
+
const isWindows = platform() === 'win32';
|
|
17
|
+
const shell = isWindows ? 'powershell.exe' : '/bin/bash';
|
|
18
|
+
const shellArgs = isWindows ? ['-c', `${command} ${args.join(' ')}`] : ['-c', `${command} ${args.join(' ')}`];
|
|
19
|
+
|
|
20
|
+
console.log(`[CLI] Executing: ${command} ${args.join(' ')}`);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
this.process = spawn(shell, shellArgs, {
|
|
24
|
+
cwd,
|
|
25
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
26
|
+
env: process.env
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
this.process.stdout?.on('data', (data) => {
|
|
30
|
+
const output = data.toString();
|
|
31
|
+
if (this.onOutputCallback) {
|
|
32
|
+
this.onOutputCallback(output);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this.process.stderr?.on('data', (data) => {
|
|
37
|
+
const output = data.toString();
|
|
38
|
+
if (this.onOutputCallback) {
|
|
39
|
+
this.onOutputCallback(output);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.process.on('error', (error) => {
|
|
44
|
+
if (this.onOutputCallback) {
|
|
45
|
+
this.onOutputCallback(`Error: ${error.message}\n`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.process.on('close', (code) => {
|
|
50
|
+
if (this.onOutputCallback) {
|
|
51
|
+
this.onOutputCallback(`\n[Process exited with code ${code}]`);
|
|
52
|
+
}
|
|
53
|
+
this.process = null;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
} catch (error: any) {
|
|
57
|
+
if (this.onOutputCallback) {
|
|
58
|
+
this.onOutputCallback(`Failed to start process: ${error.message}\n`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onOutput(callback: (data: string) => void): void {
|
|
64
|
+
this.onOutputCallback = callback;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
kill(): void {
|
|
68
|
+
if (this.process) {
|
|
69
|
+
this.process.kill();
|
|
70
|
+
this.process = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { WebRTCConnection } from './webrtc';
|
|
2
|
+
import { setupMCPServer } from './mcp-handler';
|
|
3
|
+
import { CLIAdapter } from './adapters/cli-adapter';
|
|
4
|
+
|
|
5
|
+
export class UniversalAgent {
|
|
6
|
+
private webrtc: WebRTCConnection;
|
|
7
|
+
private cliAdapter: CLIAdapter;
|
|
8
|
+
private activeTool: 'mcp' | 'cli' = 'mcp';
|
|
9
|
+
|
|
10
|
+
constructor(webrtc: WebRTCConnection) {
|
|
11
|
+
this.webrtc = webrtc;
|
|
12
|
+
this.cliAdapter = new CLIAdapter();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async start() {
|
|
16
|
+
console.log('🚀 Universal Agent starting...');
|
|
17
|
+
|
|
18
|
+
// Initialize MCP Server (Cursor integration)
|
|
19
|
+
// We run this in parallel because it sets up its own listeners
|
|
20
|
+
// Note: In the future, we might want to wrap this better
|
|
21
|
+
setupMCPServer(this.webrtc).catch(err => {
|
|
22
|
+
console.error('Failed to start MCP server:', err);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Initialize CLI Adapter listeners
|
|
26
|
+
this.cliAdapter.onOutput((data) => {
|
|
27
|
+
this.webrtc.send({
|
|
28
|
+
type: 'cli_output',
|
|
29
|
+
data: data,
|
|
30
|
+
timestamp: Date.now()
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Override the WebRTC message handler to route commands
|
|
35
|
+
// We need to be careful not to conflict with mcp-handler's listeners
|
|
36
|
+
// Currently mcp-handler adds its own listener. SimplePeer supports multiple listeners.
|
|
37
|
+
|
|
38
|
+
this.webrtc.onMessage((message) => {
|
|
39
|
+
this.handleMessage(message);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await this.webrtc.connect();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('Failed to connect WebRTC:', error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private handleMessage(message: any) {
|
|
50
|
+
// console.log('Universal Agent received:', message);
|
|
51
|
+
|
|
52
|
+
if (message.type === 'switch_tool') {
|
|
53
|
+
this.activeTool = message.tool;
|
|
54
|
+
console.log(`🔄 Switched active tool to: ${this.activeTool}`);
|
|
55
|
+
|
|
56
|
+
this.webrtc.send({
|
|
57
|
+
type: 'system',
|
|
58
|
+
message: `Switched to ${this.activeTool}`
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (message.type === 'cli_command') {
|
|
64
|
+
const command = message.command;
|
|
65
|
+
console.log(`💻 Received CLI command: ${command}`);
|
|
66
|
+
this.cliAdapter.execute(command);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// MCP commands are handled by mcp-handler.ts directly via its own listener
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as os from 'os';
|
|
7
|
+
import * as qrcode from 'qrcode-terminal';
|
|
8
|
+
import { UniversalAgent } from './agent';
|
|
9
|
+
import { WebRTCConnection } from './webrtc';
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('mobile-coder-mcp')
|
|
15
|
+
.description('MCP server for MobileCoderMCP - enables mobile to desktop coding')
|
|
16
|
+
.version('1.0.0');
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('init')
|
|
20
|
+
.description('Initialize connection, generate QR code and start the agent')
|
|
21
|
+
.option('-s, --signaling <url>', 'Signaling server URL', 'https://mcp-signal.workers.dev')
|
|
22
|
+
.action(async (options: { signaling: string }) => {
|
|
23
|
+
const code = generateConnectionCode();
|
|
24
|
+
|
|
25
|
+
console.log('\n📱 MobileCoder MCP - Quick Setup');
|
|
26
|
+
console.log('================================');
|
|
27
|
+
console.log(`\n🔑 Your Pairing Code: ${code}`);
|
|
28
|
+
console.log('Scan the QR code below with your mobile device to connect:\n');
|
|
29
|
+
|
|
30
|
+
// Generate QR code in terminal
|
|
31
|
+
// We use the full URL if we want to deep link, or just the code
|
|
32
|
+
const pairingUrl = `https://mobilecoder-mcp.app/connect?code=${code}`;
|
|
33
|
+
qrcode.generate(pairingUrl, { small: true });
|
|
34
|
+
|
|
35
|
+
console.log('\n🚀 Configuring IDEs...');
|
|
36
|
+
await autoConfigureIDEs(code, options.signaling);
|
|
37
|
+
|
|
38
|
+
console.log('\n🔌 Starting Universal Agent...');
|
|
39
|
+
const webrtc = new WebRTCConnection(code, options.signaling);
|
|
40
|
+
const agent = new UniversalAgent(webrtc);
|
|
41
|
+
await agent.start();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
program
|
|
45
|
+
.command('start')
|
|
46
|
+
.description('Start the Universal Agent with an existing code')
|
|
47
|
+
.option('-c, --code <code>', 'Connection code')
|
|
48
|
+
.option('-s, --signaling <url>', 'Signaling server URL', 'https://mcp-signal.workers.dev')
|
|
49
|
+
.action(async (options: { code?: string; signaling: string }) => {
|
|
50
|
+
if (!options.code) {
|
|
51
|
+
console.error('❌ Error: Connection code is required');
|
|
52
|
+
console.log(' Use: mobile-coder-mcp start --code=YOUR_CODE');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log('🔌 Starting Universal Agent...');
|
|
57
|
+
console.log(` Connection code: ${options.code}`);
|
|
58
|
+
console.log(` Signaling server: ${options.signaling}\n`);
|
|
59
|
+
|
|
60
|
+
const webrtc = new WebRTCConnection(options.code, options.signaling);
|
|
61
|
+
const agent = new UniversalAgent(webrtc);
|
|
62
|
+
await agent.start();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
program
|
|
66
|
+
.command('reset')
|
|
67
|
+
.description('Reset connection and remove config from all IDEs')
|
|
68
|
+
.option('-i, --ide <ide>', 'IDE to reset (cursor, windsurf, vscode, all)', 'all')
|
|
69
|
+
.action(async (options: { ide: string }) => {
|
|
70
|
+
console.log('🔄 Resetting MobileCoderMCP...\n');
|
|
71
|
+
|
|
72
|
+
const configs = {
|
|
73
|
+
cursor: path.join(os.homedir(), '.cursor', 'mcp.json'),
|
|
74
|
+
windsurf: path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json'),
|
|
75
|
+
vscode: path.join(os.homedir(), '.vscode', 'mcp.json'),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const ide = options.ide.toLowerCase();
|
|
79
|
+
|
|
80
|
+
if (ide === 'all') {
|
|
81
|
+
for (const [name, configPath] of Object.entries(configs)) {
|
|
82
|
+
await removeFromConfig(configPath, 'mobile-coder');
|
|
83
|
+
}
|
|
84
|
+
} else if (configs[ide as keyof typeof configs]) {
|
|
85
|
+
await removeFromConfig(configs[ide as keyof typeof configs], 'mobile-coder');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log('✅ Configuration reset complete\n');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
program.parse();
|
|
92
|
+
|
|
93
|
+
function generateConnectionCode(): string {
|
|
94
|
+
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function autoConfigureIDEs(code: string, signalingUrl: string) {
|
|
98
|
+
const configs = {
|
|
99
|
+
cursor: path.join(os.homedir(), '.cursor', 'mcp.json'),
|
|
100
|
+
windsurf: path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json'),
|
|
101
|
+
vscode: path.join(os.homedir(), '.vscode', 'mcp.json'),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
for (const [name, configPath] of Object.entries(configs)) {
|
|
105
|
+
try {
|
|
106
|
+
await configureIDE(configPath, name, code, signalingUrl);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// Ignore if IDE config dir doesn't exist
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function configureIDE(configPath: string, ideName: string, code: string, signalingUrl: string) {
|
|
114
|
+
const configDir = path.dirname(configPath);
|
|
115
|
+
if (!fs.existsSync(configDir)) {
|
|
116
|
+
return; // Skip if IDE is not installed/configured
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let config: any = { mcpServers: {} };
|
|
120
|
+
if (fs.existsSync(configPath)) {
|
|
121
|
+
try {
|
|
122
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
123
|
+
} catch (e) {
|
|
124
|
+
console.warn(`⚠️ Could not read existing ${ideName} config, creating new one`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!config.mcpServers) {
|
|
129
|
+
config.mcpServers = {};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// We assume the user has mobile-coder-mcp installed globally or via npx
|
|
133
|
+
// In npx context, we use the command name directly
|
|
134
|
+
config.mcpServers['mobile-coder'] = {
|
|
135
|
+
command: 'npx',
|
|
136
|
+
args: ['mobile-coder-mcp', 'start', '--code', code, '--signaling', signalingUrl],
|
|
137
|
+
env: {
|
|
138
|
+
MCP_CONNECTION_CODE: code,
|
|
139
|
+
MCP_SIGNALING_URL: signalingUrl
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
144
|
+
console.log(` ✅ ${ideName.charAt(0).toUpperCase() + ideName.slice(1)} linked.`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function removeFromConfig(configPath: string, serverName: string) {
|
|
148
|
+
if (!fs.existsSync(configPath)) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
154
|
+
if (config.mcpServers && config.mcpServers[serverName]) {
|
|
155
|
+
delete config.mcpServers[serverName];
|
|
156
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
157
|
+
console.log(` ✅ Removed from ${path.basename(configPath)}`);
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.warn(`⚠️ Could not update ${configPath}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio';
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
} from '@modelcontextprotocol/sdk/types';
|
|
7
|
+
import { WebRTCConnection } from './webrtc';
|
|
8
|
+
import {
|
|
9
|
+
validatePath,
|
|
10
|
+
validateFile,
|
|
11
|
+
validateCommand,
|
|
12
|
+
sanitizeInput,
|
|
13
|
+
sanitizePath,
|
|
14
|
+
rateLimiters,
|
|
15
|
+
securityLogger,
|
|
16
|
+
generateSecureToken
|
|
17
|
+
} from './security';
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
import * as crypto from 'crypto';
|
|
21
|
+
|
|
22
|
+
// Queue to store commands received from mobile
|
|
23
|
+
const commandQueue: string[] = [];
|
|
24
|
+
|
|
25
|
+
export async function setupMCPServer(webrtc: WebRTCConnection): Promise<void> {
|
|
26
|
+
// Create MCP server
|
|
27
|
+
const server = new Server(
|
|
28
|
+
{
|
|
29
|
+
name: 'mobile-coder-mcp',
|
|
30
|
+
version: '1.0.0',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
capabilities: {
|
|
34
|
+
tools: {},
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Set up error handling
|
|
40
|
+
server.onerror = (error: any) => {
|
|
41
|
+
console.error('[MCP Error]', error);
|
|
42
|
+
securityLogger.log('mcp_server_error', { error: error.message || 'Unknown error' }, 'medium');
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// List available tools
|
|
46
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
47
|
+
return {
|
|
48
|
+
tools: [
|
|
49
|
+
{
|
|
50
|
+
name: 'get_next_command',
|
|
51
|
+
description: 'Get next pending command from mobile device',
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'send_message',
|
|
59
|
+
description: 'Send a message or status update to mobile device',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
message: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: 'The message to send to user',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ['message'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'list_directory',
|
|
73
|
+
description: 'List files and directories in a path',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
path: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
description: 'The directory path to list (relative to cwd)',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'read_file',
|
|
86
|
+
description: 'Read contents of a file',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
path: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
description: 'The file path to read',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
required: ['path'],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Handle tool calls from MCP (Claude/Cursor)
|
|
103
|
+
server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
|
|
104
|
+
const { name, arguments: args } = request.params;
|
|
105
|
+
|
|
106
|
+
if (name === 'get_next_command') {
|
|
107
|
+
const command = commandQueue.shift();
|
|
108
|
+
if (!command) {
|
|
109
|
+
return { content: [{ type: 'text', text: 'No pending commands.' }] };
|
|
110
|
+
}
|
|
111
|
+
return { content: [{ type: 'text', text: command }] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (name === 'send_message') {
|
|
115
|
+
const message = (args as { message?: string })?.message;
|
|
116
|
+
if (!message) {
|
|
117
|
+
return { content: [{ type: 'text', text: 'Error: Message is required' }], isError: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Sanitize message content
|
|
121
|
+
const sanitizedMessage = sanitizeInput(message);
|
|
122
|
+
|
|
123
|
+
// Check if message contains diff data
|
|
124
|
+
if (typeof args === 'object' && (args as any).diff) {
|
|
125
|
+
webrtc.send({
|
|
126
|
+
type: 'result',
|
|
127
|
+
data: {
|
|
128
|
+
diff: (args as any).diff,
|
|
129
|
+
oldCode: (args as any).oldCode,
|
|
130
|
+
newCode: (args as any).newCode,
|
|
131
|
+
fileName: (args as any).fileName
|
|
132
|
+
},
|
|
133
|
+
timestamp: Date.now()
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
webrtc.send({ type: 'result', data: sanitizedMessage, timestamp: Date.now() });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { content: [{ type: 'text', text: `Message sent to mobile: ${typeof args === 'object' ? 'Diff data' : sanitizedMessage}` }] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (name === 'list_directory') {
|
|
143
|
+
try {
|
|
144
|
+
const requestId = generateSecureToken(16);
|
|
145
|
+
const fileList = await handleListDirectory(process.cwd(), args as any, requestId);
|
|
146
|
+
return { content: [{ type: 'text', text: JSON.stringify(fileList) }] };
|
|
147
|
+
} catch (error: any) {
|
|
148
|
+
return { content: [{ type: 'text', text: error.message }], isError: true };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (name === 'read_file') {
|
|
153
|
+
try {
|
|
154
|
+
const requestId = generateSecureToken(16);
|
|
155
|
+
const content = await handleReadFile(process.cwd(), args as any, requestId);
|
|
156
|
+
return { content: [{ type: 'text', text: content }] };
|
|
157
|
+
} catch (error: any) {
|
|
158
|
+
return { content: [{ type: 'text', text: error.message }], isError: true };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Connect WebRTC listeners
|
|
166
|
+
webrtc.onConnect(() => {
|
|
167
|
+
console.log('📱 [MCP] Mobile device connected');
|
|
168
|
+
securityLogger.log('mobile_device_connected', { timestamp: Date.now() }, 'low');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
webrtc.onMessage(async (message: any) => {
|
|
172
|
+
// Handle command queueing
|
|
173
|
+
if (message.type === 'command' && message.text) {
|
|
174
|
+
const sanitizedCommand = sanitizeInput(message.text);
|
|
175
|
+
|
|
176
|
+
// Rate limiting
|
|
177
|
+
if (!rateLimiters.commands.isAllowed('command')) {
|
|
178
|
+
securityLogger.logRateLimitExceeded('command', 'queue_command');
|
|
179
|
+
webrtc.send({
|
|
180
|
+
type: 'error',
|
|
181
|
+
data: 'Rate limit exceeded. Please try again later.',
|
|
182
|
+
timestamp: Date.now()
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Command validation
|
|
188
|
+
const commandValidation = validateCommand(sanitizedCommand);
|
|
189
|
+
if (!commandValidation.valid) {
|
|
190
|
+
securityLogger.logBlockedCommand(sanitizedCommand, commandValidation.error || 'Unknown reason');
|
|
191
|
+
webrtc.send({
|
|
192
|
+
type: 'error',
|
|
193
|
+
data: 'Command blocked for security reasons.',
|
|
194
|
+
timestamp: Date.now()
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log(` [MCP] Queuing command: ${sanitizedCommand}`);
|
|
200
|
+
commandQueue.push(sanitizedCommand);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Handle direct tool calls from mobile (for File Explorer)
|
|
204
|
+
if (message.type === 'tool_call') {
|
|
205
|
+
const { tool, data, id } = message;
|
|
206
|
+
console.log(`🛠️ [MCP] Tool call received: ${tool}`, data);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
let result;
|
|
210
|
+
if (tool === 'list_directory') {
|
|
211
|
+
result = await handleListDirectory(process.cwd(), data, id);
|
|
212
|
+
} else if (tool === 'read_file') {
|
|
213
|
+
result = await handleReadFile(process.cwd(), data, id);
|
|
214
|
+
} else {
|
|
215
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
webrtc.send({
|
|
219
|
+
type: 'tool_result',
|
|
220
|
+
id: id, // Echo back ID for correlation
|
|
221
|
+
tool: tool,
|
|
222
|
+
data: result,
|
|
223
|
+
timestamp: Date.now()
|
|
224
|
+
});
|
|
225
|
+
} catch (error: any) {
|
|
226
|
+
console.error(`❌ [MCP] Tool execution failed: ${error.message}`);
|
|
227
|
+
securityLogger.log('tool_execution_failed', { tool, error: error.message }, 'medium');
|
|
228
|
+
webrtc.send({
|
|
229
|
+
type: 'tool_result',
|
|
230
|
+
id: id,
|
|
231
|
+
tool: tool,
|
|
232
|
+
error: error.message,
|
|
233
|
+
timestamp: Date.now()
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Start MCP server with stdio transport
|
|
240
|
+
const transport = new StdioServerTransport();
|
|
241
|
+
await server.connect(transport);
|
|
242
|
+
|
|
243
|
+
console.log('✅ MCP Server initialized (stdio transport)');
|
|
244
|
+
securityLogger.log('mcp_server_started', { timestamp: Date.now() }, 'low');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Helper functions for file system operations
|
|
248
|
+
async function handleListDirectory(cwd: string, args: { path?: string }, requestId?: string) {
|
|
249
|
+
const dirPath = args?.path || '.';
|
|
250
|
+
const sanitizedPath = sanitizePath(dirPath);
|
|
251
|
+
|
|
252
|
+
// Rate limiting
|
|
253
|
+
if (!rateLimiters.fileOperations.isAllowed(requestId || 'unknown')) {
|
|
254
|
+
securityLogger.logRateLimitExceeded(requestId || 'unknown', 'list_directory');
|
|
255
|
+
throw new Error('Rate limit exceeded for directory operations');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Security validation
|
|
259
|
+
const pathValidation = validatePath(sanitizedPath, cwd);
|
|
260
|
+
if (!pathValidation.valid) {
|
|
261
|
+
securityLogger.logPathTraversal(sanitizedPath, path.resolve(cwd, sanitizedPath));
|
|
262
|
+
throw new Error(`Access denied: ${pathValidation.error}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const stats = await fs.promises.stat(path.resolve(cwd, sanitizedPath));
|
|
267
|
+
if (!stats.isDirectory()) {
|
|
268
|
+
throw new Error('Path is not a directory');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const files = await fs.promises.readdir(path.resolve(cwd, sanitizedPath), { withFileTypes: true });
|
|
272
|
+
const fileList = files.map((f) => ({
|
|
273
|
+
name: f.name,
|
|
274
|
+
isDirectory: f.isDirectory(),
|
|
275
|
+
path: path.join(sanitizedPath, f.name).replace(/\\/g, '/'), // Normalize paths
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
// Sort: directories first, then files
|
|
279
|
+
fileList.sort((a, b) => {
|
|
280
|
+
if (a.isDirectory === b.isDirectory) {
|
|
281
|
+
return a.name.localeCompare(b.name);
|
|
282
|
+
}
|
|
283
|
+
return a.isDirectory ? -1 : 1;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return fileList;
|
|
287
|
+
} catch (error: any) {
|
|
288
|
+
securityLogger.log('directory_list_error', { path: sanitizedPath, error: error.message }, 'medium');
|
|
289
|
+
throw new Error(`Error listing directory: ${error.message}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function handleReadFile(cwd: string, args: { path?: string }, requestId?: string) {
|
|
294
|
+
const filePath = args?.path;
|
|
295
|
+
if (!filePath) throw new Error('Path is required');
|
|
296
|
+
|
|
297
|
+
const sanitizedPath = sanitizePath(filePath);
|
|
298
|
+
|
|
299
|
+
// Rate limiting
|
|
300
|
+
if (!rateLimiters.fileOperations.isAllowed(requestId || 'unknown')) {
|
|
301
|
+
securityLogger.logRateLimitExceeded(requestId || 'unknown', 'read_file');
|
|
302
|
+
throw new Error('Rate limit exceeded for file operations');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Security validation
|
|
306
|
+
const fileValidation = validateFile(sanitizedPath, cwd);
|
|
307
|
+
if (!fileValidation.valid) {
|
|
308
|
+
securityLogger.log('file_access_denied', { path: sanitizedPath, reason: fileValidation.error }, 'high');
|
|
309
|
+
throw new Error(`Access denied: ${fileValidation.error}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const fullPath = path.resolve(cwd, sanitizedPath);
|
|
314
|
+
const stats = await fs.promises.stat(fullPath);
|
|
315
|
+
if (stats.isDirectory()) {
|
|
316
|
+
throw new Error('Path is a directory, not a file');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return await fs.promises.readFile(fullPath, 'utf-8');
|
|
320
|
+
} catch (error: any) {
|
|
321
|
+
securityLogger.log('file_read_error', { path: sanitizedPath, error: error.message }, 'medium');
|
|
322
|
+
throw new Error(`Error reading file: ${error.message}`);
|
|
323
|
+
}
|
|
324
|
+
}
|