Skip to content

Commit f14a401

Browse files
authored
[expo-mcp] merge automation tools (#4)
# Why to reduce token usage, we can merge some mcp tools # How - `automation_tap` and `automation_tap_by_testid` into `automation_tap` that can pass either x/y or testID - `automation_take_screenshot` and `automation_take_screenshot_by_testid` into `automation_take_screenshot` that can pass either x/y or testID - rename `automation_find_view_by_testid` to `automation_find_view`
1 parent 3d9a1f9 commit f14a401

File tree

2 files changed

+126
-146
lines changed

2 files changed

+126
-146
lines changed

packages/expo-mcp/src/mcp/tools.ts

Lines changed: 3 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { type McpServerProxy } from '@expo/mcp-tunnel';
2-
import fs from 'node:fs';
32
import { z } from 'zod';
4-
import { $, tmpfile, within } from 'zx';
3+
import { $, within } from 'zx';
54

65
import { AutomationFactory } from '../automation/AutomationFactory.js';
76
import { createLogCollector } from '../develop/LogCollectorFactory.js';
87
import { findDevServerUrlAsync, openDevtoolsAsync } from '../develop/devtools.js';
9-
import { resizeImageToMaxSizeAsync } from '../imageUtils.js';
108
import { isExpoRouterProject } from '../project.js';
9+
import { addAutomationTools } from './tools/automation.js';
1110

1211
export function addMcpTools(server: McpServerProxy, projectRoot: string) {
1312
const isRouterProject = isExpoRouterProject(projectRoot);
@@ -142,147 +141,5 @@ export function addMcpTools(server: McpServerProxy, projectRoot: string) {
142141
}
143142
);
144143

145-
//#region automation tools
146-
147-
server.registerTool(
148-
'automation_tap',
149-
{
150-
title: 'Tap on device',
151-
description: 'Tap on the device at the given coordinates',
152-
inputSchema: {
153-
projectRoot: z.string(),
154-
platform: z.enum(['android', 'ios']).optional(),
155-
x: z.number(),
156-
y: z.number(),
157-
},
158-
},
159-
async ({ projectRoot, platform: platformParam, x, y }) => {
160-
const platform = platformParam ?? (await AutomationFactory.guessCurrentPlatformAsync());
161-
const deviceId = await AutomationFactory.getBootedDeviceIdAsync(platform);
162-
const appId = await AutomationFactory.getAppIdAsync({ projectRoot, platform, deviceId });
163-
const automation = AutomationFactory.create(platform, {
164-
appId,
165-
deviceId,
166-
});
167-
const result = await automation.tapAsync({ x, y });
168-
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
169-
}
170-
);
171-
172-
server.registerTool(
173-
'automation_take_screenshot',
174-
{
175-
title: 'Take screenshot of the app',
176-
description:
177-
'Take screenshot of the app. This is useful to verify the app is visually correct',
178-
inputSchema: {
179-
projectRoot: z.string(),
180-
platform: z.enum(['android', 'ios']).optional(),
181-
},
182-
},
183-
async ({ projectRoot, platform: platformParam }) => {
184-
const platform = platformParam ?? (await AutomationFactory.guessCurrentPlatformAsync());
185-
const deviceId = await AutomationFactory.getBootedDeviceIdAsync(platform);
186-
const appId = await AutomationFactory.getAppIdAsync({ projectRoot, platform, deviceId });
187-
const outputPath = `${tmpfile()}.png`;
188-
try {
189-
const automation = AutomationFactory.create(platform, {
190-
appId,
191-
deviceId,
192-
});
193-
await automation.takeFullScreenshotAsync({ outputPath });
194-
const { buffer } = await resizeImageToMaxSizeAsync(outputPath);
195-
return {
196-
content: [{ type: 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
197-
};
198-
} finally {
199-
await fs.promises.rm(outputPath, { force: true });
200-
}
201-
}
202-
);
203-
204-
server.registerTool(
205-
'automation_find_view_by_testid',
206-
{
207-
title: 'Find view properties by react-native testID',
208-
description:
209-
'Find view and dump its properties by react-native testID. This is useful to verify the view is rendered correctly',
210-
inputSchema: {
211-
projectRoot: z.string(),
212-
platform: z.enum(['android', 'ios']).optional(),
213-
testID: z.string(),
214-
},
215-
},
216-
async ({ projectRoot, platform: platformParam, testID }) => {
217-
const platform = platformParam ?? (await AutomationFactory.guessCurrentPlatformAsync());
218-
const deviceId = await AutomationFactory.getBootedDeviceIdAsync(platform);
219-
const appId = await AutomationFactory.getAppIdAsync({ projectRoot, platform, deviceId });
220-
const automation = AutomationFactory.create(platform, {
221-
appId,
222-
deviceId,
223-
});
224-
const result = await automation.findViewByTestIDAsync(testID);
225-
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
226-
}
227-
);
228-
229-
server.registerTool(
230-
'automation_tap_by_testid',
231-
{
232-
title: 'Tap on the view by react-native testID',
233-
description:
234-
'Tap on the view specified by react-native testID. This is useful to interact with the view',
235-
inputSchema: {
236-
projectRoot: z.string(),
237-
platform: z.enum(['android', 'ios']).optional(),
238-
testID: z.string(),
239-
},
240-
},
241-
async ({ projectRoot, platform: platformParam, testID }) => {
242-
const platform = platformParam ?? (await AutomationFactory.guessCurrentPlatformAsync());
243-
const deviceId = await AutomationFactory.getBootedDeviceIdAsync(platform);
244-
const appId = await AutomationFactory.getAppIdAsync({ projectRoot, platform, deviceId });
245-
const automation = AutomationFactory.create(platform, {
246-
appId,
247-
deviceId,
248-
});
249-
const result = await automation.tapByTestIDAsync(testID);
250-
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
251-
}
252-
);
253-
254-
server.registerTool(
255-
'automation_take_screenshot_by_testid',
256-
{
257-
title: 'Take screenshot of the app by react-native testID',
258-
description:
259-
'Take screenshot of the app by react-native testID. This is useful to verify the view is rendered correctly',
260-
inputSchema: {
261-
projectRoot: z.string(),
262-
platform: z.enum(['android', 'ios']).optional(),
263-
testID: z.string(),
264-
},
265-
},
266-
async ({ projectRoot, platform: platformParam, testID }) => {
267-
const platform = platformParam ?? (await AutomationFactory.guessCurrentPlatformAsync());
268-
const deviceId = await AutomationFactory.getBootedDeviceIdAsync(platform);
269-
const appId = await AutomationFactory.getAppIdAsync({ projectRoot, platform, deviceId });
270-
const outputPath = `${tmpfile()}.png`;
271-
try {
272-
const automation = AutomationFactory.create(platform, {
273-
appId,
274-
deviceId,
275-
});
276-
await automation.taksScreenshotByTestIDAsync({ testID, outputPath });
277-
const { buffer } = await resizeImageToMaxSizeAsync(outputPath);
278-
return {
279-
content: [{ type: 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
280-
};
281-
} finally {
282-
await fs.promises.rm(outputPath, { force: true });
283-
}
284-
}
285-
);
286-
287-
//#endregion automation tools
144+
addAutomationTools(server, projectRoot);
288145
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { type McpServerProxy } from '@expo/mcp-tunnel';
2+
import fs from 'node:fs';
3+
import { z } from 'zod';
4+
import { tmpfile } from 'zx';
5+
6+
import type { IAutomation } from '../../automation/Automation.types.js';
7+
import { AutomationFactory } from '../../automation/AutomationFactory.js';
8+
import { resizeImageToMaxSizeAsync } from '../../imageUtils.js';
9+
10+
type AutomationContext = {
11+
automation: IAutomation;
12+
platform: 'android' | 'ios';
13+
deviceId: string;
14+
appId: string;
15+
};
16+
17+
async function getAutomationContext(
18+
projectRoot: string,
19+
platformParam?: 'android' | 'ios'
20+
): Promise<AutomationContext> {
21+
const platform = platformParam ?? (await AutomationFactory.guessCurrentPlatformAsync());
22+
const deviceId = await AutomationFactory.getBootedDeviceIdAsync(platform);
23+
const appId = await AutomationFactory.getAppIdAsync({ projectRoot, platform, deviceId });
24+
const automation = AutomationFactory.create(platform, { appId, deviceId });
25+
return { automation, platform, deviceId, appId };
26+
}
27+
28+
export function addAutomationTools(server: McpServerProxy, projectRoot: string) {
29+
server.registerTool(
30+
'automation_tap',
31+
{
32+
title: 'Tap on device',
33+
description:
34+
'Tap on the device at the given coordinates (x, y) or by react-native testID. Provide either (x AND y) or testID.',
35+
inputSchema: {
36+
projectRoot: z.string(),
37+
platform: z.enum(['android', 'ios']).optional(),
38+
x: z.number().optional().describe('X coordinate for tap (required if testID not provided)'),
39+
y: z.number().optional().describe('Y coordinate for tap (required if testID not provided)'),
40+
testID: z
41+
.string()
42+
.optional()
43+
.describe('React Native testID of the view to tap (alternative to x,y coordinates)'),
44+
},
45+
},
46+
async ({ projectRoot, platform, x, y, testID }) => {
47+
if (testID) {
48+
const { automation } = await getAutomationContext(projectRoot, platform);
49+
const result = await automation.tapByTestIDAsync(testID);
50+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
51+
} else if (x !== undefined && y !== undefined) {
52+
const { automation } = await getAutomationContext(projectRoot, platform);
53+
const result = await automation.tapAsync({ x, y });
54+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
55+
} else {
56+
return {
57+
content: [
58+
{
59+
type: 'text',
60+
text: 'Error: Must provide either testID or both x and y coordinates',
61+
},
62+
],
63+
isError: true,
64+
};
65+
}
66+
}
67+
);
68+
69+
server.registerTool(
70+
'automation_take_screenshot',
71+
{
72+
title: 'Take screenshot of the app',
73+
description:
74+
'Take screenshot of the full app or a specific view by react-native testID. Optionally provide testID to screenshot a specific view.',
75+
inputSchema: {
76+
projectRoot: z.string(),
77+
platform: z.enum(['android', 'ios']).optional(),
78+
testID: z
79+
.string()
80+
.optional()
81+
.describe(
82+
'React Native testID of the view to screenshot (if not provided, takes full screen)'
83+
),
84+
},
85+
},
86+
async ({ projectRoot, platform, testID }) => {
87+
const { automation } = await getAutomationContext(projectRoot, platform);
88+
const outputPath = `${tmpfile()}.png`;
89+
try {
90+
if (testID) {
91+
await automation.taksScreenshotByTestIDAsync({ testID, outputPath });
92+
} else {
93+
await automation.takeFullScreenshotAsync({ outputPath });
94+
}
95+
const { buffer } = await resizeImageToMaxSizeAsync(outputPath);
96+
return {
97+
content: [{ type: 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
98+
};
99+
} finally {
100+
await fs.promises.rm(outputPath, { force: true });
101+
}
102+
}
103+
);
104+
105+
server.registerTool(
106+
'automation_find_view',
107+
{
108+
title: 'Find view properties',
109+
description:
110+
'Find view and dump its properties. This is useful to verify the view is rendered correctly',
111+
inputSchema: {
112+
projectRoot: z.string(),
113+
platform: z.enum(['android', 'ios']).optional(),
114+
testID: z.string().describe('React Native testID of the view to inspect'),
115+
},
116+
},
117+
async ({ projectRoot, platform, testID }) => {
118+
const { automation } = await getAutomationContext(projectRoot, platform);
119+
const result = await automation.findViewByTestIDAsync(testID);
120+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
121+
}
122+
);
123+
}

0 commit comments

Comments
 (0)