@momo-kits/mcp-expo 1.0.3 → 2.0.0-beta.2
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/package.json +3 -2
- package/remote-server.mjs +313 -0
- package/server.mjs +143 -226
- package/template/.expo/README.md +8 -0
- package/template/.expo/devices.json +3 -0
- package/template/App.tsx +161 -0
- package/template/app.json +10 -0
- package/template/metro.config.js +7 -0
- package/template/package-lock.json +8120 -0
- package/template/package.json +20 -0
- package/template/tsconfig.json +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@momo-kits/mcp-expo",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-beta.2",
|
|
4
4
|
"description": "MCP server: push AI-generated code to Expo and get LAN URL for Expo Go demo",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.mjs",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"mcp-expo": "./server.mjs"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"start": "node server.mjs"
|
|
11
|
+
"start": "node server.mjs",
|
|
12
|
+
"remote": "node remote-server.mjs"
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
14
15
|
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Remote Expo Server
|
|
4
|
+
*
|
|
5
|
+
* HTTP API chạy trên server (có sẵn Expo + dependencies).
|
|
6
|
+
* Nhận code từ MCP client, ghi vào App.tsx, chạy Metro, trả URL.
|
|
7
|
+
*
|
|
8
|
+
* Endpoints:
|
|
9
|
+
* POST /push - { code, reset? } → ghi App.tsx, start/reload Metro
|
|
10
|
+
* POST /stop - Dừng Metro
|
|
11
|
+
* GET /status - Trạng thái Metro + Expo URL
|
|
12
|
+
* GET /health - Health check
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node remote-server.mjs # default port 3210
|
|
16
|
+
* PORT=4000 node remote-server.mjs # custom port
|
|
17
|
+
* API_KEY=secret123 node remote-server.mjs # with auth
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import http from 'http';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import os from 'os';
|
|
24
|
+
import { spawn } from 'child_process';
|
|
25
|
+
import { fileURLToPath } from 'url';
|
|
26
|
+
|
|
27
|
+
// ── Config ───────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const PORT = parseInt(process.env.PORT || '3210', 10);
|
|
30
|
+
const API_KEY = process.env.API_KEY || ''; // empty = no auth
|
|
31
|
+
const TEMPLATE_DIR = process.env.MCP_EXPO_TEMPLATE_DIR
|
|
32
|
+
|| path.join(path.dirname(fileURLToPath(import.meta.url)), 'template');
|
|
33
|
+
const APP_FILE = path.join(TEMPLATE_DIR, 'App.tsx');
|
|
34
|
+
const METRO_START_TIMEOUT_MS = 90_000;
|
|
35
|
+
|
|
36
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
let metroProcess = null;
|
|
39
|
+
let expoUrl = null;
|
|
40
|
+
let isExpoRunning = false;
|
|
41
|
+
|
|
42
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function log(...args) {
|
|
45
|
+
console.log(`[remote-expo ${new Date().toISOString()}]`, ...args);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeAppTsx(code) {
|
|
49
|
+
fs.writeFileSync(APP_FILE, code, 'utf8');
|
|
50
|
+
log(`Wrote App.tsx (${code.length} chars)`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getLocalIP() {
|
|
54
|
+
const nets = os.networkInterfaces();
|
|
55
|
+
for (const name of Object.keys(nets)) {
|
|
56
|
+
for (const net of nets[name]) {
|
|
57
|
+
if (net.family === 'IPv4' && !net.internal) {
|
|
58
|
+
return net.address;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extractExpoUrl(line) {
|
|
66
|
+
const lanMatch = line.match(/(exp(?:o)?:\/\/(?:10\.\d+\.\d+.\d+|172\.(?:1[6-9]|2\d|3[01])\.\d+|\d+\.\d+\.\d+.\d+|192\.168\.\d+.\d+):\d+)/);
|
|
67
|
+
if (lanMatch) return lanMatch[1];
|
|
68
|
+
const expMatch = line.match(/(exp(?:o)?:\/\/[^\s]+)/);
|
|
69
|
+
if (expMatch) return expMatch[1];
|
|
70
|
+
const tunnelMatch = line.match(/(https?:\/\/[a-zA-Z0-9.-]+\.tunnel\.expo\.test)/);
|
|
71
|
+
if (tunnelMatch) return tunnelMatch[1];
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isMetroAlive() {
|
|
76
|
+
return metroProcess !== null && metroProcess.exitCode === null && !metroProcess.killed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stopMetro() {
|
|
80
|
+
if (!metroProcess) return;
|
|
81
|
+
metroProcess.kill('SIGTERM');
|
|
82
|
+
metroProcess = null;
|
|
83
|
+
isExpoRunning = false;
|
|
84
|
+
expoUrl = null;
|
|
85
|
+
log('Metro stopped.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function ensureDeps() {
|
|
89
|
+
const nodeModulesPath = path.join(TEMPLATE_DIR, 'node_modules');
|
|
90
|
+
if (!fs.existsSync(nodeModulesPath)) {
|
|
91
|
+
log('Installing template dependencies...');
|
|
92
|
+
await new Promise((res, rej) => {
|
|
93
|
+
const install = spawn('npm', ['install', '--silent'], { cwd: TEMPLATE_DIR, stdio: 'pipe' });
|
|
94
|
+
install.on('close', (code) => code === 0 ? res() : rej(new Error(`npm install failed (code ${code})`)));
|
|
95
|
+
});
|
|
96
|
+
log('Dependencies installed.');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function startExpoMetro() {
|
|
101
|
+
await ensureDeps();
|
|
102
|
+
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
let settled = false;
|
|
105
|
+
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
if (!settled) {
|
|
108
|
+
settled = true;
|
|
109
|
+
reject(new Error(`Metro start timeout (${METRO_START_TIMEOUT_MS / 1000}s).`));
|
|
110
|
+
}
|
|
111
|
+
}, METRO_START_TIMEOUT_MS);
|
|
112
|
+
|
|
113
|
+
log(`Starting Expo Metro in ${TEMPLATE_DIR}...`);
|
|
114
|
+
|
|
115
|
+
metroProcess = spawn('npx', ['expo', 'start', '--lan', '--port', '8081'], {
|
|
116
|
+
cwd: TEMPLATE_DIR,
|
|
117
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
118
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
metroProcess.stdout.setEncoding('utf8');
|
|
122
|
+
metroProcess.stderr.setEncoding('utf8');
|
|
123
|
+
|
|
124
|
+
const onData = (streamName) => (data) => {
|
|
125
|
+
if (settled && streamName === 'stdout') return; // reduce noise after settled
|
|
126
|
+
const text = data.toString();
|
|
127
|
+
const lines = text.split('\n');
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
if (line.trim()) log(`[${streamName}]`, line.trim());
|
|
130
|
+
|
|
131
|
+
if (line.includes('Use port') && line.includes('?')) {
|
|
132
|
+
metroProcess.stdin.write('y\n');
|
|
133
|
+
log('[stdin] sent "y"');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!settled) {
|
|
137
|
+
const url = extractExpoUrl(line);
|
|
138
|
+
if (url) {
|
|
139
|
+
expoUrl = url;
|
|
140
|
+
settled = true;
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
resolve(url);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (line.includes('Waiting on http://localhost:')) {
|
|
146
|
+
const localIP = getLocalIP();
|
|
147
|
+
if (localIP) {
|
|
148
|
+
expoUrl = `exp://${localIP}:8081`;
|
|
149
|
+
log(`Detected LAN IP: ${localIP} → ${expoUrl}`);
|
|
150
|
+
settled = true;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
resolve(expoUrl);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
metroProcess.stdout.on('data', onData('stdout'));
|
|
161
|
+
metroProcess.stderr.on('data', onData('stderr'));
|
|
162
|
+
|
|
163
|
+
metroProcess.on('error', (err) => {
|
|
164
|
+
if (!settled) { settled = true; clearTimeout(timer); reject(new Error(`Metro error: ${err.message}`)); }
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
metroProcess.on('exit', (code) => {
|
|
168
|
+
if (!settled) {
|
|
169
|
+
settled = true; clearTimeout(timer);
|
|
170
|
+
reject(new Error(`Metro exited unexpectedly (code ${code}).`));
|
|
171
|
+
} else {
|
|
172
|
+
isExpoRunning = false;
|
|
173
|
+
log(`Metro exited (code ${code}) — state reset.`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
function readBody(req) {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
let body = '';
|
|
184
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
185
|
+
req.on('end', () => resolve(body));
|
|
186
|
+
req.on('error', reject);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function json(res, status, data) {
|
|
191
|
+
const payload = JSON.stringify(data);
|
|
192
|
+
res.writeHead(status, {
|
|
193
|
+
'Content-Type': 'application/json',
|
|
194
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
195
|
+
});
|
|
196
|
+
res.end(payload);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function checkAuth(req, res) {
|
|
200
|
+
if (!API_KEY) return true;
|
|
201
|
+
const auth = req.headers['authorization'] || '';
|
|
202
|
+
if (auth === `Bearer ${API_KEY}`) return true;
|
|
203
|
+
json(res, 401, { ok: false, message: 'Unauthorized' });
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Routes ───────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
async function handlePush(req, res) {
|
|
210
|
+
try {
|
|
211
|
+
const body = JSON.parse(await readBody(req));
|
|
212
|
+
const { code, reset = false } = body;
|
|
213
|
+
|
|
214
|
+
if (!code || typeof code !== 'string') {
|
|
215
|
+
return json(res, 400, { ok: false, message: 'Missing "code" field' });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
writeAppTsx(code);
|
|
219
|
+
|
|
220
|
+
if (reset || !isMetroAlive()) {
|
|
221
|
+
stopMetro();
|
|
222
|
+
await startExpoMetro();
|
|
223
|
+
isExpoRunning = true;
|
|
224
|
+
} else {
|
|
225
|
+
log('Code updated — Metro running, HMR will reload.');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
json(res, 200, {
|
|
229
|
+
ok: true,
|
|
230
|
+
message: isMetroAlive()
|
|
231
|
+
? 'Code updated. App reloading via HMR.'
|
|
232
|
+
: 'Done. Scan QR with Expo Go.',
|
|
233
|
+
expoUrl,
|
|
234
|
+
});
|
|
235
|
+
} catch (err) {
|
|
236
|
+
log('Error in /push:', err.message);
|
|
237
|
+
json(res, 500, { ok: false, message: err.message });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function handleStop(req, res) {
|
|
242
|
+
stopMetro();
|
|
243
|
+
json(res, 200, { ok: true, message: 'Metro stopped.' });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function handleStatus(req, res) {
|
|
247
|
+
json(res, 200, {
|
|
248
|
+
isRunning: isMetroAlive(),
|
|
249
|
+
expoUrl,
|
|
250
|
+
pid: metroProcess ? metroProcess.pid : null,
|
|
251
|
+
templateDir: TEMPLATE_DIR,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function handleHealth(req, res) {
|
|
256
|
+
json(res, 200, { ok: true, uptime: process.uptime() });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── HTTP Server ──────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
const server = http.createServer(async (req, res) => {
|
|
262
|
+
// CORS for convenience
|
|
263
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
264
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
265
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
266
|
+
|
|
267
|
+
if (req.method === 'OPTIONS') {
|
|
268
|
+
res.writeHead(204);
|
|
269
|
+
return res.end();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
273
|
+
const route = `${req.method} ${url.pathname}`;
|
|
274
|
+
|
|
275
|
+
log(`→ ${route}`);
|
|
276
|
+
|
|
277
|
+
if (!checkAuth(req, res)) return;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
if (route === 'POST /push') return await handlePush(req, res);
|
|
281
|
+
if (route === 'POST /stop') return await handleStop(req, res);
|
|
282
|
+
if (route === 'GET /status') return await handleStatus(req, res);
|
|
283
|
+
if (route === 'GET /health') return await handleHealth(req, res);
|
|
284
|
+
|
|
285
|
+
json(res, 404, { ok: false, message: 'Not found' });
|
|
286
|
+
} catch (err) {
|
|
287
|
+
log('Unhandled error:', err);
|
|
288
|
+
json(res, 500, { ok: false, message: 'Internal server error' });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── Start ────────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
295
|
+
log(`Remote Expo server listening on http://0.0.0.0:${PORT}`);
|
|
296
|
+
log(`Template dir: ${TEMPLATE_DIR}`);
|
|
297
|
+
if (API_KEY) log('Auth: API key required');
|
|
298
|
+
else log('Auth: disabled (set API_KEY env to enable)');
|
|
299
|
+
log('');
|
|
300
|
+
log('Endpoints:');
|
|
301
|
+
log(` POST http://localhost:${PORT}/push - Push code & run`);
|
|
302
|
+
log(` POST http://localhost:${PORT}/stop - Stop Metro`);
|
|
303
|
+
log(` GET http://localhost:${PORT}/status - Get status`);
|
|
304
|
+
log(` GET http://localhost:${PORT}/health - Health check`);
|
|
305
|
+
log('');
|
|
306
|
+
log('Expose via ngrok:');
|
|
307
|
+
log(` ngrok http ${PORT}`);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
process.on('SIGTERM', () => { log('SIGTERM'); stopMetro(); process.exit(0); });
|
|
313
|
+
process.on('SIGINT', () => { log('SIGINT'); stopMetro(); process.exit(0); });
|