@momo-kits/mcp-expo 1.0.2 → 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 +3 -2
- package/remote-server.mjs +313 -0
- package/server.mjs +84 -220
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.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); });
|
package/server.mjs
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* MCP Server: expo
|
|
3
|
+
* MCP Server: mcp-expo (thin client)
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* MCP server chạy trên máy user, forward tool calls tới remote HTTP server
|
|
6
|
+
* (nơi đã cài sẵn Expo + dependencies). User không cần cài gì.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Env var MCP_EXPO_REMOTE_URL có thể override URL mặc định (để tự host).
|
|
9
|
+
*
|
|
10
|
+
* Usage (in Claude Desktop / MCP config):
|
|
11
|
+
* {
|
|
12
|
+
* "mcpServers": {
|
|
13
|
+
* "expo": {
|
|
14
|
+
* "command": "npx",
|
|
15
|
+
* "args": ["-y", "@momo-kits/mcp-expo"]
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
10
19
|
*/
|
|
11
20
|
|
|
12
21
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
@@ -15,25 +24,13 @@ import {
|
|
|
15
24
|
CallToolRequestSchema,
|
|
16
25
|
ListToolsRequestSchema,
|
|
17
26
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
18
|
-
import path from 'path';
|
|
19
|
-
import fs from 'fs';
|
|
20
|
-
import os from 'os';
|
|
21
|
-
import { spawn } from 'child_process';
|
|
22
|
-
import { fileURLToPath } from 'url';
|
|
23
27
|
|
|
24
28
|
// ── Config ───────────────────────────────────────────────────────────────────
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
// ── State ─────────────────────────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
let metroProcess = null;
|
|
34
|
-
let expoUrl = null;
|
|
35
|
-
let isExpoRunning = false;
|
|
36
|
-
let isShuttingDown = false;
|
|
30
|
+
// Default: server của bạn. User có thể override bằng env var nếu tự host.
|
|
31
|
+
const DEFAULT_REMOTE_URL = 'http://localhost:3210';
|
|
32
|
+
const REMOTE_URL = (process.env.MCP_EXPO_REMOTE_URL || DEFAULT_REMOTE_URL).replace(/\/+$/, '');
|
|
33
|
+
const API_KEY = process.env.MCP_EXPO_API_KEY || '';
|
|
37
34
|
|
|
38
35
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
39
36
|
|
|
@@ -41,178 +38,53 @@ function log(...args) {
|
|
|
41
38
|
console.error('[mcp-expo]', ...args);
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const nets = os.networkInterfaces();
|
|
51
|
-
for (const name of Object.keys(nets)) {
|
|
52
|
-
for (const net of nets[name]) {
|
|
53
|
-
if (net.family === 'IPv4' && !net.internal) {
|
|
54
|
-
return net.address;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function extractExpoUrl(line) {
|
|
62
|
-
// LAN URL: exp://192.168.x.x:8081 ← ưu tiên cao nhất (--lan flag)
|
|
63
|
-
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+)/);
|
|
64
|
-
if (lanMatch) return lanMatch[1];
|
|
65
|
-
// Any exp:// URL
|
|
66
|
-
const expMatch = line.match(/(exp(?:o)?:\/\/[^\s]+)/);
|
|
67
|
-
if (expMatch) return expMatch[1];
|
|
68
|
-
// Tunnel URL
|
|
69
|
-
const tunnelMatch = line.match(/(https?:\/\/[a-zA-Z0-9.-]+\.tunnel\.expo\.test)/);
|
|
70
|
-
if (tunnelMatch) return tunnelMatch[1];
|
|
71
|
-
// Bỏ qua localhost
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function startExpoMetro() {
|
|
76
|
-
// Auto-install dependencies if node_modules missing
|
|
77
|
-
const nodeModulesPath = path.join(TEMPLATE_DIR, 'node_modules');
|
|
78
|
-
if (!fs.existsSync(nodeModulesPath)) {
|
|
79
|
-
log('Installing template dependencies...');
|
|
80
|
-
await new Promise((res, rej) => {
|
|
81
|
-
const install = spawn('npm', ['install', '--silent'], { cwd: TEMPLATE_DIR, stdio: 'pipe' });
|
|
82
|
-
install.on('close', (code) => code === 0 ? res() : rej(new Error(`npm install failed (code ${code})`)));
|
|
83
|
-
});
|
|
84
|
-
log('Dependencies installed.');
|
|
41
|
+
async function remoteCall(method, endpoint, body) {
|
|
42
|
+
if (!REMOTE_URL) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'MCP_EXPO_REMOTE_URL not set. ' +
|
|
45
|
+
'Set it to the URL of your remote Expo server (e.g. https://xxx.ngrok-free.app).'
|
|
46
|
+
);
|
|
85
47
|
}
|
|
86
48
|
|
|
87
|
-
|
|
88
|
-
|
|
49
|
+
const url = `${REMOTE_URL}${endpoint}`;
|
|
50
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
51
|
+
if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`;
|
|
89
52
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
settled = true;
|
|
93
|
-
reject(new Error(
|
|
94
|
-
`Metro start timeout (${METRO_START_TIMEOUT_MS / 1000}s). ` +
|
|
95
|
-
`Run 'npx expo start --lan' manually in ${TEMPLATE_DIR}.`
|
|
96
|
-
));
|
|
97
|
-
}
|
|
98
|
-
}, METRO_START_TIMEOUT_MS);
|
|
53
|
+
const opts = { method, headers };
|
|
54
|
+
if (body) opts.body = JSON.stringify(body);
|
|
99
55
|
|
|
100
|
-
|
|
56
|
+
log(`→ ${method} ${url}`);
|
|
101
57
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
105
|
-
env: { ...process.env, FORCE_COLOR: '0' },
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
metroProcess.stdout.setEncoding('utf8');
|
|
109
|
-
metroProcess.stderr.setEncoding('utf8');
|
|
110
|
-
|
|
111
|
-
const onData = (streamName) => (data) => {
|
|
112
|
-
if (settled) return;
|
|
113
|
-
const text = data.toString();
|
|
114
|
-
const lines = text.split('\n');
|
|
115
|
-
for (const line of lines) {
|
|
116
|
-
if (line.trim()) {
|
|
117
|
-
log(`[${streamName}]`, line.trim());
|
|
118
|
-
}
|
|
119
|
-
// Expo prompts for interactive input
|
|
120
|
-
if (line.includes('Use port') && line.includes('?')) {
|
|
121
|
-
metroProcess.stdin.write('y\n');
|
|
122
|
-
log('[stdin] sent "y"');
|
|
123
|
-
}
|
|
124
|
-
const url = extractExpoUrl(line);
|
|
125
|
-
if (url && !settled) {
|
|
126
|
-
expoUrl = url;
|
|
127
|
-
settled = true;
|
|
128
|
-
clearTimeout(timer);
|
|
129
|
-
resolve(url);
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
// Expo chỉ log "Waiting on localhost" khi dùng --lan, không in LAN URL
|
|
133
|
-
if (!settled && line.includes('Waiting on http://localhost:')) {
|
|
134
|
-
const localIP = getLocalIP();
|
|
135
|
-
if (localIP) {
|
|
136
|
-
expoUrl = `exp://${localIP}:8081`;
|
|
137
|
-
log(`Detected LAN IP: ${localIP} → ${expoUrl}`);
|
|
138
|
-
settled = true;
|
|
139
|
-
clearTimeout(timer);
|
|
140
|
-
resolve(expoUrl);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
};
|
|
58
|
+
const resp = await fetch(url, opts);
|
|
59
|
+
const data = await resp.json();
|
|
146
60
|
|
|
147
|
-
|
|
148
|
-
metroProcess.stderr.on('data', onData('stderr'));
|
|
61
|
+
log(`← ${resp.status}`, JSON.stringify(data));
|
|
149
62
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
clearTimeout(timer);
|
|
154
|
-
reject(new Error(`Metro process error: ${err.message}`));
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
metroProcess.on('exit', (code) => {
|
|
159
|
-
if (!settled) {
|
|
160
|
-
settled = true;
|
|
161
|
-
clearTimeout(timer);
|
|
162
|
-
reject(new Error(`Metro exited unexpectedly (code ${code}).`));
|
|
163
|
-
} else {
|
|
164
|
-
// Metro crashed after startup — reset state so next call restarts cleanly
|
|
165
|
-
isExpoRunning = false;
|
|
166
|
-
log(`Metro exited (code ${code}) — state reset.`);
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function stopMetro() {
|
|
173
|
-
if (!metroProcess) return;
|
|
174
|
-
metroProcess.kill('SIGTERM');
|
|
175
|
-
metroProcess = null;
|
|
176
|
-
isExpoRunning = false;
|
|
177
|
-
expoUrl = null;
|
|
178
|
-
log('Metro stopped.');
|
|
179
|
-
}
|
|
63
|
+
if (!resp.ok) {
|
|
64
|
+
throw new Error(data.message || `Remote server error (${resp.status})`);
|
|
65
|
+
}
|
|
180
66
|
|
|
181
|
-
|
|
182
|
-
return metroProcess !== null && metroProcess.exitCode === null && !metroProcess.killed;
|
|
67
|
+
return data;
|
|
183
68
|
}
|
|
184
69
|
|
|
185
70
|
// ── Tool Implementations ──────────────────────────────────────────────────────
|
|
186
71
|
|
|
187
72
|
async function handlePushScreen({ code, reset = false }) {
|
|
188
73
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
isExpoRunning = true;
|
|
195
|
-
} else {
|
|
196
|
-
log('Code updated. Metro running — app will reload via HMR.');
|
|
74
|
+
if (!code || typeof code !== 'string') {
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, message: 'Missing "code" field' }) }],
|
|
77
|
+
isError: true,
|
|
78
|
+
};
|
|
197
79
|
}
|
|
198
80
|
|
|
81
|
+
const data = await remoteCall('POST', '/push', { code, reset });
|
|
82
|
+
|
|
199
83
|
return {
|
|
200
|
-
content: [
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
{
|
|
205
|
-
ok: true,
|
|
206
|
-
message: isExpoRunning
|
|
207
|
-
? 'Code updated. App is reloading via HMR — tap "r" in terminal or pull to refresh.'
|
|
208
|
-
: 'Done. Scan QR with Expo Go.',
|
|
209
|
-
expoUrl,
|
|
210
|
-
},
|
|
211
|
-
null,
|
|
212
|
-
2
|
|
213
|
-
),
|
|
214
|
-
},
|
|
215
|
-
],
|
|
84
|
+
content: [{
|
|
85
|
+
type: 'text',
|
|
86
|
+
text: JSON.stringify(data, null, 2),
|
|
87
|
+
}],
|
|
216
88
|
};
|
|
217
89
|
} catch (err) {
|
|
218
90
|
log('Error:', err.message);
|
|
@@ -224,49 +96,57 @@ async function handlePushScreen({ code, reset = false }) {
|
|
|
224
96
|
}
|
|
225
97
|
|
|
226
98
|
async function handleStopExpo() {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
99
|
+
try {
|
|
100
|
+
const data = await remoteCall('POST', '/stop');
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
103
|
+
};
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, message: err.message }) }],
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
231
110
|
}
|
|
232
111
|
|
|
233
112
|
async function handleGetStatus() {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
{
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
113
|
+
try {
|
|
114
|
+
const data = await remoteCall('GET', '/status');
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
117
|
+
};
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return {
|
|
120
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, message: err.message }) }],
|
|
121
|
+
isError: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
243
124
|
}
|
|
244
125
|
|
|
245
126
|
// ── MCP Server ─────────────────────────────────────────────────────────────────
|
|
246
127
|
|
|
247
128
|
const server = new Server(
|
|
248
|
-
{ name: 'mcp-expo', version: '
|
|
129
|
+
{ name: 'mcp-expo', version: '2.0.0' },
|
|
249
130
|
{ capabilities: { tools: {} } }
|
|
250
131
|
);
|
|
251
132
|
|
|
252
|
-
// Tool definitions
|
|
253
133
|
const tools = [
|
|
254
134
|
{
|
|
255
135
|
name: 'push_screen_and_run',
|
|
256
136
|
description:
|
|
257
|
-
'
|
|
258
|
-
'
|
|
259
|
-
'
|
|
137
|
+
'Push TSX/JSX code to the remote Expo server. ' +
|
|
138
|
+
'The code replaces App.tsx and the app reloads via HMR. ' +
|
|
139
|
+
'Returns the exp:// URL to open with Expo Go.',
|
|
260
140
|
inputSchema: {
|
|
261
141
|
type: 'object',
|
|
262
142
|
properties: {
|
|
263
143
|
code: {
|
|
264
144
|
type: 'string',
|
|
265
|
-
description: '
|
|
145
|
+
description: 'Full App.tsx content (TSX/JSX). Must export default a React component.',
|
|
266
146
|
},
|
|
267
147
|
reset: {
|
|
268
148
|
type: 'boolean',
|
|
269
|
-
description: 'true = kill + restart Metro
|
|
149
|
+
description: 'true = kill + restart Metro before running. Default: false.',
|
|
270
150
|
},
|
|
271
151
|
},
|
|
272
152
|
required: ['code'],
|
|
@@ -274,20 +154,17 @@ const tools = [
|
|
|
274
154
|
},
|
|
275
155
|
{
|
|
276
156
|
name: 'stop_expo',
|
|
277
|
-
description: '
|
|
157
|
+
description: 'Stop the Metro server on the remote Expo server.',
|
|
278
158
|
inputSchema: { type: 'object', properties: {} },
|
|
279
159
|
},
|
|
280
160
|
{
|
|
281
161
|
name: 'get_expo_status',
|
|
282
|
-
description: '
|
|
162
|
+
description: 'Get Metro server status and current Expo URL from the remote server.',
|
|
283
163
|
inputSchema: { type: 'object', properties: {} },
|
|
284
164
|
},
|
|
285
165
|
];
|
|
286
166
|
|
|
287
|
-
|
|
288
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
289
|
-
tools,
|
|
290
|
-
}));
|
|
167
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
291
168
|
|
|
292
169
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
293
170
|
const { name, arguments: args } = request.params;
|
|
@@ -303,27 +180,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
303
180
|
};
|
|
304
181
|
});
|
|
305
182
|
|
|
306
|
-
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
307
|
-
|
|
308
|
-
process.on('SIGTERM', () => {
|
|
309
|
-
if (isShuttingDown) return;
|
|
310
|
-
isShuttingDown = true;
|
|
311
|
-
log('SIGTERM received — shutting down.');
|
|
312
|
-
stopMetro();
|
|
313
|
-
process.exit(0);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
process.on('SIGINT', () => {
|
|
317
|
-
if (isShuttingDown) return;
|
|
318
|
-
isShuttingDown = true;
|
|
319
|
-
log('SIGINT received — shutting down.');
|
|
320
|
-
stopMetro();
|
|
321
|
-
process.exit(0);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
183
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
325
184
|
|
|
326
185
|
async function main() {
|
|
186
|
+
log(`Remote server: ${REMOTE_URL}`);
|
|
187
|
+
if (REMOTE_URL.includes('YOUR-NGROK-URL')) {
|
|
188
|
+
log('WARNING: Default ngrok URL chưa được cập nhật! Hãy sửa DEFAULT_REMOTE_URL trong server.mjs hoặc set MCP_EXPO_REMOTE_URL.');
|
|
189
|
+
}
|
|
190
|
+
|
|
327
191
|
const transport = new StdioServerTransport();
|
|
328
192
|
await server.connect(transport);
|
|
329
193
|
log('MCP server connected. Ready.');
|