@momo-kits/mcp-expo 1.0.3 → 2.0.0-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/mcp-expo",
3
- "version": "1.0.3",
3
+ "version": "2.0.0-beta.1",
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); });