@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/mcp-expo",
3
- "version": "1.0.2",
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-demo
3
+ * MCP Server: mcp-expo (thin client)
4
4
  *
5
- * Nhận code từ AI tool, ghi vào App.tsx trong expo-demo-template,
6
- * chạy Expo tunnel, trả exp:// URL + QR để demo trên Expo Go.
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
- * Usage:
9
- * node server.mjs
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
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
- const TEMPLATE_DIR = path.join(__dirname, 'template');
28
- const APP_FILE = path.join(TEMPLATE_DIR, 'App.tsx');
29
- const METRO_START_TIMEOUT_MS = 90_000;
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 writeAppTsx(code) {
45
- fs.writeFileSync(APP_FILE, code, 'utf8');
46
- log(`Wrote App.tsx (${code.length} chars)`);
47
- }
48
-
49
- function getLocalIP() {
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
- return new Promise((resolve, reject) => {
88
- let settled = false;
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
- const timer = setTimeout(() => {
91
- if (!settled) {
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
- log(`Starting Expo Metro in ${TEMPLATE_DIR}...`);
56
+ log(`→ ${method} ${url}`);
101
57
 
102
- metroProcess = spawn('npx', ['expo', 'start', '--lan', '--port', '8081'], {
103
- cwd: TEMPLATE_DIR,
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
- metroProcess.stdout.on('data', onData('stdout'));
148
- metroProcess.stderr.on('data', onData('stderr'));
61
+ log(`← ${resp.status}`, JSON.stringify(data));
149
62
 
150
- metroProcess.on('error', (err) => {
151
- if (!settled) {
152
- settled = true;
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
- function isMetroAlive() {
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
- writeAppTsx(code);
190
-
191
- if (reset || !isMetroAlive()) {
192
- stopMetro();
193
- await startExpoMetro();
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
- type: 'text',
203
- text: JSON.stringify(
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
- stopMetro();
228
- return {
229
- content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Metro stopped.' }) }],
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
- const running = isMetroAlive();
235
- return {
236
- content: [
237
- {
238
- type: 'text',
239
- text: JSON.stringify({ isRunning: running, expoUrl, pid: metroProcess ? metroProcess.pid : null }),
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: '1.0.0' },
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
- 'Nhận code TSX/JSX từ AI, ghi vào App.tsx trong expo-demo-template, ' +
258
- 'chạy (hoặc reload) Expo Metro tunnel và trả exp:// URL để mở app trên Expo Go. ' +
259
- 'Nếu Metro đang chạy, app sẽ reload tự động qua HMR.',
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: 'Nội dung TSX/JSX từ AI. thể full file hoặc snippet.',
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 trước khi chạy. Mặc định: false.',
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: 'Dừng Metro server đang chạy.',
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: 'Kiểm tra trạng thái Metro server lấy Expo URL hiện tại.',
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
- // Register handlers
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.');