@momo-kits/mcp-expo 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/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # MCP Expo — AI Code → Expo Go Demo
2
+
3
+ MCP server nhận code từ AI tool, ghi vào `expo-demo-template/App.tsx`, và trả URL để mở app trên Expo Go.
4
+
5
+ ## Cấu trúc
6
+
7
+ ```
8
+ /Users/sonnguyen/Documents/Momo/
9
+ ├── expo-demo-template/ ← App Expo cố định (Metro đọc ở đây)
10
+ │ ├── App.tsx ← File bị ghi đè mỗi lần gọi tool
11
+ │ ├── app.json
12
+ │ └── package.json
13
+
14
+ └── mcp-expo/ ← MCP server
15
+ ├── server.mjs ← MCP server (ESM, stdio)
16
+ └── package.json
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ ```bash
22
+ cd /Users/sonnguyen/Documents/Momo/mcp-expo
23
+ npm install
24
+ ```
25
+
26
+ ## Chạy MCP
27
+
28
+ ```bash
29
+ node server.mjs
30
+ ```
31
+
32
+ Cấu hình MCP client (Claude Code / Claude Desktop):
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "expo-demo": {
38
+ "command": "node",
39
+ "args": ["/Users/sonnguyen/Documents/Momo/mcp-expo/server.mjs"]
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ ## Tools
46
+
47
+ ### `push_screen_and_run`
48
+
49
+ Ghi code AI vào `App.tsx`, khởi động Metro nếu cần, trả URL.
50
+
51
+ ```json
52
+ {
53
+ "code": "import React from 'react';\nimport { View, Text } from 'react-native';\n\nexport default function Demo() {\n return <View><Text>Hello!</Text></View>;\n}",
54
+ "reset": false
55
+ }
56
+ ```
57
+
58
+ | Param | Type | Mô tả |
59
+ |-------|------|--------|
60
+ | `code` | string | Nội dung TSX/JSX từ AI |
61
+ | `reset` | boolean | `true` = kill + restart Metro trước. Mặc định: `false` |
62
+
63
+ Response:
64
+ ```json
65
+ {
66
+ "ok": true,
67
+ "message": "Code updated. App is reloading via HMR...",
68
+ "expoUrl": "http://localhost:8081"
69
+ }
70
+ ```
71
+
72
+ ### `stop_expo`
73
+ Kill Metro server đang chạy.
74
+
75
+ ### `get_expo_status`
76
+ Kiểm tra trạng thái:
77
+ ```json
78
+ {
79
+ "isRunning": true,
80
+ "expoUrl": "http://localhost:8081",
81
+ "pid": 12345
82
+ }
83
+ ```
84
+
85
+ ## Flow
86
+
87
+ 1. AI gen code → gọi `push_screen_and_run`
88
+ 2. MCP ghi code vào `App.tsx`
89
+ 3. Metro bundler khởi động (hoặc reload HMR nếu đang chạy)
90
+ 4. Trả URL → mở Expo Go trên điện thoại, trỏ vào URL đó
91
+ 5. App hiện màn hình AI vừa gen
92
+
93
+ ## Lưu ý
94
+
95
+ - Metro chạy ở `--lan` (cùng WiFi). URL: `http://<IP>:8081`
96
+ - Expo Go cần kết nối cùng mạng WiFi với máy tính
97
+ - `CI=1` được set tự động để tránh interactive prompts
98
+ - Nếu máy có firewall, allow port 8081
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@momo-kits/mcp-expo",
3
+ "version": "1.0.0",
4
+ "description": "MCP server: push AI-generated code to Expo and get LAN URL for Expo Go demo",
5
+ "type": "module",
6
+ "main": "server.mjs",
7
+ "bin": {
8
+ "mcp-expo": "./server.mjs"
9
+ },
10
+ "scripts": {
11
+ "start": "node server.mjs"
12
+ },
13
+ "dependencies": {
14
+ "@modelcontextprotocol/sdk": "^1.0.0"
15
+ },
16
+ "keywords": ["mcp", "expo", "react-native", "ai", "demo"],
17
+ "license": "MIT",
18
+ "engines": {
19
+ "node": ">=18"
20
+ }
21
+ }
package/server.mjs ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * MCP Server: expo-demo
3
+ *
4
+ * Nhận code từ AI tool, ghi vào App.tsx trong expo-demo-template,
5
+ * chạy Expo tunnel, trả exp:// URL + QR để demo trên Expo Go.
6
+ *
7
+ * Usage:
8
+ * node server.mjs
9
+ */
10
+
11
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
+ import {
14
+ CallToolRequestSchema,
15
+ ListToolsRequestSchema,
16
+ } from '@modelcontextprotocol/sdk/types.js';
17
+ import path from 'path';
18
+ import fs from 'fs';
19
+ import os from 'os';
20
+ import { spawn } from 'child_process';
21
+ import { fileURLToPath } from 'url';
22
+
23
+ // ── Config ───────────────────────────────────────────────────────────────────
24
+
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+ const TEMPLATE_DIR = path.join(__dirname, 'template');
27
+ const APP_FILE = path.join(TEMPLATE_DIR, 'App.tsx');
28
+ const METRO_START_TIMEOUT_MS = 90_000;
29
+
30
+ // ── State ─────────────────────────────────────────────────────────────────────
31
+
32
+ let metroProcess = null;
33
+ let expoUrl = null;
34
+ let isExpoRunning = false;
35
+
36
+ // ── Helpers ───────────────────────────────────────────────────────────────────
37
+
38
+ function log(...args) {
39
+ console.error('[mcp-expo]', ...args);
40
+ }
41
+
42
+ function writeAppTsx(code) {
43
+ fs.writeFileSync(APP_FILE, code, 'utf8');
44
+ log(`Wrote App.tsx (${code.length} chars)`);
45
+ }
46
+
47
+ function getLocalIP() {
48
+ const nets = os.networkInterfaces();
49
+ for (const name of Object.keys(nets)) {
50
+ for (const net of nets[name]) {
51
+ if (net.family === 'IPv4' && !net.internal) {
52
+ return net.address;
53
+ }
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function extractExpoUrl(line) {
60
+ // LAN URL: exp://192.168.x.x:8081 ← ưu tiên cao nhất (--lan flag)
61
+ 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+)/);
62
+ if (lanMatch) return lanMatch[1];
63
+ // Any exp:// URL
64
+ const expMatch = line.match(/(exp(?:o)?:\/\/[^\s]+)/);
65
+ if (expMatch) return expMatch[1];
66
+ // Tunnel URL
67
+ const tunnelMatch = line.match(/(https?:\/\/[a-zA-Z0-9.-]+\.tunnel\.expo\.test)/);
68
+ if (tunnelMatch) return tunnelMatch[1];
69
+ // Bỏ qua localhost
70
+ return null;
71
+ }
72
+
73
+ async function startExpoMetro() {
74
+ // Auto-install dependencies if node_modules missing
75
+ const nodeModulesPath = path.join(TEMPLATE_DIR, 'node_modules');
76
+ if (!fs.existsSync(nodeModulesPath)) {
77
+ log('Installing template dependencies...');
78
+ await new Promise((res, rej) => {
79
+ const install = spawn('npm', ['install', '--silent'], { cwd: TEMPLATE_DIR, stdio: 'pipe' });
80
+ install.on('close', (code) => code === 0 ? res() : rej(new Error(`npm install failed (code ${code})`)));
81
+ });
82
+ log('Dependencies installed.');
83
+ }
84
+
85
+ return new Promise((resolve, reject) => {
86
+ let settled = false;
87
+
88
+ const timer = setTimeout(() => {
89
+ if (!settled) {
90
+ settled = true;
91
+ reject(new Error(
92
+ `Metro start timeout (${METRO_START_TIMEOUT_MS / 1000}s). ` +
93
+ `Run 'npx expo start --lan' manually in ${TEMPLATE_DIR}.`
94
+ ));
95
+ }
96
+ }, METRO_START_TIMEOUT_MS);
97
+
98
+ log(`Starting Expo Metro in ${TEMPLATE_DIR}...`);
99
+
100
+ metroProcess = spawn('npx', ['expo', 'start', '--lan', '--port', '8081'], {
101
+ cwd: TEMPLATE_DIR,
102
+ stdio: ['pipe', 'pipe', 'pipe'],
103
+ env: { ...process.env, FORCE_COLOR: '0' },
104
+ });
105
+
106
+ metroProcess.stdout.setEncoding('utf8');
107
+ metroProcess.stderr.setEncoding('utf8');
108
+
109
+ const onData = (streamName) => (data) => {
110
+ if (settled) return;
111
+ const text = data.toString();
112
+ const lines = text.split('\n');
113
+ for (const line of lines) {
114
+ if (line.trim()) {
115
+ log(`[${streamName}]`, line.trim());
116
+ }
117
+ // Expo prompts for interactive input
118
+ if (line.includes('Use port') && line.includes('?')) {
119
+ metroProcess.stdin.write('y\n');
120
+ log('[stdin] sent "y"');
121
+ }
122
+ const url = extractExpoUrl(line);
123
+ if (url && !settled) {
124
+ expoUrl = url;
125
+ settled = true;
126
+ clearTimeout(timer);
127
+ resolve(url);
128
+ return;
129
+ }
130
+ // Expo chỉ log "Waiting on localhost" khi dùng --lan, không in LAN URL
131
+ if (!settled && line.includes('Waiting on http://localhost:')) {
132
+ const localIP = getLocalIP();
133
+ if (localIP) {
134
+ expoUrl = `exp://${localIP}:8081`;
135
+ log(`Detected LAN IP: ${localIP} → ${expoUrl}`);
136
+ settled = true;
137
+ clearTimeout(timer);
138
+ resolve(expoUrl);
139
+ return;
140
+ }
141
+ }
142
+ }
143
+ };
144
+
145
+ metroProcess.stdout.on('data', onData('stdout'));
146
+ metroProcess.stderr.on('data', onData('stderr'));
147
+
148
+ metroProcess.on('error', (err) => {
149
+ if (!settled) {
150
+ settled = true;
151
+ clearTimeout(timer);
152
+ reject(new Error(`Metro process error: ${err.message}`));
153
+ }
154
+ });
155
+
156
+ metroProcess.on('exit', (code) => {
157
+ if (!settled) {
158
+ settled = true;
159
+ clearTimeout(timer);
160
+ reject(new Error(`Metro exited unexpectedly (code ${code}).`));
161
+ }
162
+ });
163
+ });
164
+ }
165
+
166
+ function stopMetro() {
167
+ if (metroProcess) {
168
+ metroProcess.kill('SIGTERM');
169
+ metroProcess = null;
170
+ isExpoRunning = false;
171
+ expoUrl = null;
172
+ log('Metro stopped.');
173
+ }
174
+ }
175
+
176
+ // ── Tool Implementations ──────────────────────────────────────────────────────
177
+
178
+ async function handlePushScreen({ code, reset = false }) {
179
+ try {
180
+ writeAppTsx(code);
181
+
182
+ if (reset || !metroProcess || metroProcess.exitCode !== null) {
183
+ stopMetro();
184
+ await startExpoMetro();
185
+ isExpoRunning = true;
186
+ } else {
187
+ log('Code updated. Metro running — app will reload via HMR.');
188
+ }
189
+
190
+ return {
191
+ content: [
192
+ {
193
+ type: 'text',
194
+ text: JSON.stringify(
195
+ {
196
+ ok: true,
197
+ message: isExpoRunning
198
+ ? 'Code updated. App is reloading via HMR — tap "r" in terminal or pull to refresh.'
199
+ : 'Done. Scan QR with Expo Go.',
200
+ expoUrl,
201
+ },
202
+ null,
203
+ 2
204
+ ),
205
+ },
206
+ ],
207
+ };
208
+ } catch (err) {
209
+ log('Error:', err.message);
210
+ return {
211
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, message: err.message }) }],
212
+ isError: true,
213
+ };
214
+ }
215
+ }
216
+
217
+ async function handleStopExpo() {
218
+ stopMetro();
219
+ return {
220
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Metro stopped.' }) }],
221
+ };
222
+ }
223
+
224
+ async function handleGetStatus() {
225
+ const running = isExpoRunning && metroProcess !== null && metroProcess.exitCode === null;
226
+ return {
227
+ content: [
228
+ {
229
+ type: 'text',
230
+ text: JSON.stringify({ isRunning: running, expoUrl, pid: metroProcess ? metroProcess.pid : null }),
231
+ },
232
+ ],
233
+ };
234
+ }
235
+
236
+ // ── MCP Server ─────────────────────────────────────────────────────────────────
237
+
238
+ const server = new Server(
239
+ { name: 'mcp-expo', version: '1.0.0' },
240
+ { capabilities: { tools: {} } }
241
+ );
242
+
243
+ // Tool definitions
244
+ const tools = [
245
+ {
246
+ name: 'push_screen_and_run',
247
+ description:
248
+ 'Nhận code TSX/JSX từ AI, ghi vào App.tsx trong expo-demo-template, ' +
249
+ 'chạy (hoặc reload) Expo Metro tunnel và trả exp:// URL để mở app trên Expo Go. ' +
250
+ 'Nếu Metro đang chạy, app sẽ reload tự động qua HMR.',
251
+ inputSchema: {
252
+ type: 'object',
253
+ properties: {
254
+ code: {
255
+ type: 'string',
256
+ description: 'Nội dung TSX/JSX từ AI. Có thể là full file hoặc snippet.',
257
+ },
258
+ reset: {
259
+ type: 'boolean',
260
+ description: 'true = kill + restart Metro trước khi chạy. Mặc định: false.',
261
+ },
262
+ },
263
+ required: ['code'],
264
+ },
265
+ },
266
+ {
267
+ name: 'stop_expo',
268
+ description: 'Dừng Metro server đang chạy.',
269
+ inputSchema: { type: 'object', properties: {} },
270
+ },
271
+ {
272
+ name: 'get_expo_status',
273
+ description: 'Kiểm tra trạng thái Metro server và lấy Expo URL hiện tại.',
274
+ inputSchema: { type: 'object', properties: {} },
275
+ },
276
+ ];
277
+
278
+ // Register handlers
279
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
280
+ tools,
281
+ }));
282
+
283
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
284
+ const { name, arguments: args } = request.params;
285
+ log(`Tool call: ${name}`);
286
+
287
+ if (name === 'push_screen_and_run') return await handlePushScreen(args || {});
288
+ if (name === 'stop_expo') return await handleStopExpo();
289
+ if (name === 'get_expo_status') return await handleGetStatus();
290
+
291
+ return {
292
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
293
+ isError: true,
294
+ };
295
+ });
296
+
297
+ // ── Main ─────────────────────────────────────────────────────────────────────
298
+
299
+ async function main() {
300
+ const transport = new StdioServerTransport();
301
+ await server.connect(transport);
302
+ log('MCP server connected. Ready.');
303
+ }
304
+
305
+ main().catch((err) => {
306
+ log('Fatal:', err);
307
+ process.exit(1);
308
+ });
@@ -0,0 +1,8 @@
1
+ > Why do I have a folder named ".expo" in my project?
2
+ The ".expo" folder is created when an Expo project is started using "expo start" command.
3
+ > What do the files contain?
4
+ - "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
5
+ - "settings.json": contains the server configuration that is used to serve the application manifest.
6
+ > Should I commit the ".expo" folder?
7
+ No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
8
+ Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
@@ -0,0 +1,3 @@
1
+ {
2
+ "devices": []
3
+ }
@@ -0,0 +1,147 @@
1
+ import React, {useContext} from 'react';
2
+ import {ApplicationContext, Button, IconSources} from '@momo-kits/foundation';
3
+ import {Alert, View} from 'react-native';
4
+ import DemoScreen from '../../template/DemoScreen';
5
+ import {ItemPickerProps} from '../../template/BottomSheetPicker';
6
+
7
+ const iconData: ItemPickerProps[] = Object.keys(IconSources).map(item => {
8
+ return {
9
+ id: item,
10
+ value: item,
11
+ title: item,
12
+ };
13
+ });
14
+
15
+ const ButtonUsage: React.FC<any> = props => {
16
+ const {navigator} = useContext(ApplicationContext);
17
+ const preview = {
18
+ title: {
19
+ title: 'Title',
20
+ value: [
21
+ {
22
+ value: 'Open',
23
+ props: {
24
+ onPress: () => {
25
+ navigator?.push({
26
+ options: {title: 'Test'},
27
+ screen: () => (
28
+ <View style={{flex: 1, backgroundColor: 'red'}} />
29
+ ),
30
+ });
31
+ },
32
+ },
33
+ },
34
+ ],
35
+ },
36
+ type: {
37
+ title: 'Type',
38
+ value: [
39
+ 'primary',
40
+ 'tonal',
41
+ 'secondary',
42
+ 'outline',
43
+ 'text',
44
+ {
45
+ value: 'danger',
46
+ props: {
47
+ onPress: () => {
48
+ console.log('ABC');
49
+ },
50
+ },
51
+ },
52
+ {
53
+ value: 'disabled',
54
+ props: {
55
+ onPress: () => {
56
+ Alert.alert('ABC');
57
+ },
58
+ },
59
+ },
60
+ ],
61
+ },
62
+ size: {
63
+ title: 'Size',
64
+ value: ['large', 'medium', 'small'],
65
+ },
66
+ loading: {
67
+ title: 'Loading',
68
+ value: [true],
69
+ },
70
+ iconRight: {
71
+ title: 'Icon right',
72
+ value: [
73
+ 'https://img.mservice.com.vn/momo_app_v2/new_version/img/appx_icon/16_arrow_arrow_bold_reply_all.png',
74
+ ],
75
+ },
76
+ iconLeft: {
77
+ title: 'Icon left',
78
+ value: [
79
+ {
80
+ value:
81
+ 'https://img.mservice.io/momo_app_v2/new_version/img/appx_image/ic_common_logo_momo.png',
82
+ props: {
83
+ useTintColor: false,
84
+ },
85
+ },
86
+ ],
87
+ },
88
+ };
89
+ const params = {
90
+ component: Button,
91
+ props: {
92
+ title: {
93
+ value: 'Button',
94
+ type: 'string',
95
+ },
96
+ type: {
97
+ value: 'primary',
98
+ type: 'enum',
99
+ data: [
100
+ 'primary',
101
+ 'secondary',
102
+ 'tonal',
103
+ 'danger',
104
+ 'outline',
105
+ 'text',
106
+ 'disabled',
107
+ ],
108
+ },
109
+ size: {
110
+ value: 'small',
111
+ type: 'enum',
112
+ data: ['small', 'medium', 'large'],
113
+ },
114
+ full: {
115
+ value: true,
116
+ type: 'bool',
117
+ },
118
+ iconRight: {
119
+ type: 'options',
120
+ data: iconData,
121
+ },
122
+ iconLeft: {
123
+ type: 'options',
124
+ data: iconData,
125
+ },
126
+ loading: {
127
+ value: true,
128
+ type: 'bool',
129
+ },
130
+ useTintColor: {
131
+ value: true,
132
+ type: 'bool',
133
+ },
134
+ },
135
+ };
136
+
137
+ return (
138
+ <DemoScreen
139
+ component={Button}
140
+ preview={preview}
141
+ playground={params}
142
+ {...props}
143
+ />
144
+ );
145
+ };
146
+
147
+ export default ButtonUsage;
@@ -0,0 +1,10 @@
1
+ {
2
+ "expo": {
3
+ "name": "Expo Demo Template",
4
+ "slug": "expo-demo-template",
5
+ "version": "1.0.0",
6
+ "orientation": "portrait",
7
+ "userInterfaceStyle": "automatic",
8
+ "assetBundlePatterns": ["**/*"]
9
+ }
10
+ }