devlens-mcp 0.3.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.
Files changed (175) hide show
  1. package/.claude/settings.json +12 -0
  2. package/.claude/settings.local.json +17 -0
  3. package/INSTALLATION_GUIDE.md +354 -0
  4. package/QUICK_START.md +153 -0
  5. package/README.md +354 -0
  6. package/bin/cli.ts +22 -0
  7. package/bin/register.ts +96 -0
  8. package/dist/bin/cli.d.ts +3 -0
  9. package/dist/bin/cli.d.ts.map +1 -0
  10. package/dist/bin/cli.js +20 -0
  11. package/dist/bin/cli.js.map +1 -0
  12. package/dist/bin/register.d.ts +10 -0
  13. package/dist/bin/register.d.ts.map +1 -0
  14. package/dist/bin/register.js +92 -0
  15. package/dist/bin/register.js.map +1 -0
  16. package/dist/src/config/devlens-config.d.ts +92 -0
  17. package/dist/src/config/devlens-config.d.ts.map +1 -0
  18. package/dist/src/config/devlens-config.js +70 -0
  19. package/dist/src/config/devlens-config.js.map +1 -0
  20. package/dist/src/index.d.ts +35 -0
  21. package/dist/src/index.d.ts.map +1 -0
  22. package/dist/src/index.js +8 -0
  23. package/dist/src/index.js.map +1 -0
  24. package/dist/src/metro/cdp-client.d.ts +48 -0
  25. package/dist/src/metro/cdp-client.d.ts.map +1 -0
  26. package/dist/src/metro/cdp-client.js +127 -0
  27. package/dist/src/metro/cdp-client.js.map +1 -0
  28. package/dist/src/metro/log-collector.d.ts +30 -0
  29. package/dist/src/metro/log-collector.d.ts.map +1 -0
  30. package/dist/src/metro/log-collector.js +114 -0
  31. package/dist/src/metro/log-collector.js.map +1 -0
  32. package/dist/src/metro/metro-bridge.d.ts +56 -0
  33. package/dist/src/metro/metro-bridge.d.ts.map +1 -0
  34. package/dist/src/metro/metro-bridge.js +255 -0
  35. package/dist/src/metro/metro-bridge.js.map +1 -0
  36. package/dist/src/metro/network-inspector.d.ts +34 -0
  37. package/dist/src/metro/network-inspector.d.ts.map +1 -0
  38. package/dist/src/metro/network-inspector.js +100 -0
  39. package/dist/src/metro/network-inspector.js.map +1 -0
  40. package/dist/src/platform/android/adb.d.ts +50 -0
  41. package/dist/src/platform/android/adb.d.ts.map +1 -0
  42. package/dist/src/platform/android/adb.js +137 -0
  43. package/dist/src/platform/android/adb.js.map +1 -0
  44. package/dist/src/platform/android/android-device.d.ts +21 -0
  45. package/dist/src/platform/android/android-device.d.ts.map +1 -0
  46. package/dist/src/platform/android/android-device.js +94 -0
  47. package/dist/src/platform/android/android-device.js.map +1 -0
  48. package/dist/src/platform/android/ui-automator.d.ts +17 -0
  49. package/dist/src/platform/android/ui-automator.d.ts.map +1 -0
  50. package/dist/src/platform/android/ui-automator.js +126 -0
  51. package/dist/src/platform/android/ui-automator.js.map +1 -0
  52. package/dist/src/platform/device-manager.d.ts +28 -0
  53. package/dist/src/platform/device-manager.d.ts.map +1 -0
  54. package/dist/src/platform/device-manager.js +185 -0
  55. package/dist/src/platform/device-manager.js.map +1 -0
  56. package/dist/src/platform/device.d.ts +86 -0
  57. package/dist/src/platform/device.d.ts.map +1 -0
  58. package/dist/src/platform/device.js +7 -0
  59. package/dist/src/platform/device.js.map +1 -0
  60. package/dist/src/platform/ios/accessibility.d.ts +17 -0
  61. package/dist/src/platform/ios/accessibility.d.ts.map +1 -0
  62. package/dist/src/platform/ios/accessibility.js +159 -0
  63. package/dist/src/platform/ios/accessibility.js.map +1 -0
  64. package/dist/src/platform/ios/ios-device.d.ts +22 -0
  65. package/dist/src/platform/ios/ios-device.d.ts.map +1 -0
  66. package/dist/src/platform/ios/ios-device.js +97 -0
  67. package/dist/src/platform/ios/ios-device.js.map +1 -0
  68. package/dist/src/platform/ios/simctl.d.ts +54 -0
  69. package/dist/src/platform/ios/simctl.d.ts.map +1 -0
  70. package/dist/src/platform/ios/simctl.js +192 -0
  71. package/dist/src/platform/ios/simctl.js.map +1 -0
  72. package/dist/src/server.d.ts +3 -0
  73. package/dist/src/server.d.ts.map +1 -0
  74. package/dist/src/server.js +176 -0
  75. package/dist/src/server.js.map +1 -0
  76. package/dist/src/snapshot/formatter.d.ts +18 -0
  77. package/dist/src/snapshot/formatter.d.ts.map +1 -0
  78. package/dist/src/snapshot/formatter.js +86 -0
  79. package/dist/src/snapshot/formatter.js.map +1 -0
  80. package/dist/src/snapshot/ref-registry.d.ts +67 -0
  81. package/dist/src/snapshot/ref-registry.d.ts.map +1 -0
  82. package/dist/src/snapshot/ref-registry.js +169 -0
  83. package/dist/src/snapshot/ref-registry.js.map +1 -0
  84. package/dist/src/snapshot/snapshot-differ.d.ts +57 -0
  85. package/dist/src/snapshot/snapshot-differ.d.ts.map +1 -0
  86. package/dist/src/snapshot/snapshot-differ.js +153 -0
  87. package/dist/src/snapshot/snapshot-differ.js.map +1 -0
  88. package/dist/src/tools/app-tools.d.ts +71 -0
  89. package/dist/src/tools/app-tools.d.ts.map +1 -0
  90. package/dist/src/tools/app-tools.js +97 -0
  91. package/dist/src/tools/app-tools.js.map +1 -0
  92. package/dist/src/tools/device-tools.d.ts +53 -0
  93. package/dist/src/tools/device-tools.d.ts.map +1 -0
  94. package/dist/src/tools/device-tools.js +86 -0
  95. package/dist/src/tools/device-tools.js.map +1 -0
  96. package/dist/src/tools/ds-tools.d.ts +65 -0
  97. package/dist/src/tools/ds-tools.d.ts.map +1 -0
  98. package/dist/src/tools/ds-tools.js +314 -0
  99. package/dist/src/tools/ds-tools.js.map +1 -0
  100. package/dist/src/tools/interaction-tools.d.ts +248 -0
  101. package/dist/src/tools/interaction-tools.d.ts.map +1 -0
  102. package/dist/src/tools/interaction-tools.js +391 -0
  103. package/dist/src/tools/interaction-tools.js.map +1 -0
  104. package/dist/src/tools/metro-tools.d.ts +115 -0
  105. package/dist/src/tools/metro-tools.d.ts.map +1 -0
  106. package/dist/src/tools/metro-tools.js +270 -0
  107. package/dist/src/tools/metro-tools.js.map +1 -0
  108. package/dist/src/tools/navigation-tools.d.ts +36 -0
  109. package/dist/src/tools/navigation-tools.d.ts.map +1 -0
  110. package/dist/src/tools/navigation-tools.js +60 -0
  111. package/dist/src/tools/navigation-tools.js.map +1 -0
  112. package/dist/src/tools/screenshot-tools.d.ts +298 -0
  113. package/dist/src/tools/screenshot-tools.d.ts.map +1 -0
  114. package/dist/src/tools/screenshot-tools.js +565 -0
  115. package/dist/src/tools/screenshot-tools.js.map +1 -0
  116. package/dist/src/tools/snapshot-tools.d.ts +161 -0
  117. package/dist/src/tools/snapshot-tools.d.ts.map +1 -0
  118. package/dist/src/tools/snapshot-tools.js +479 -0
  119. package/dist/src/tools/snapshot-tools.js.map +1 -0
  120. package/dist/src/utils/image-preprocess.d.ts +49 -0
  121. package/dist/src/utils/image-preprocess.d.ts.map +1 -0
  122. package/dist/src/utils/image-preprocess.js +322 -0
  123. package/dist/src/utils/image-preprocess.js.map +1 -0
  124. package/dist/src/utils/retry.d.ts +21 -0
  125. package/dist/src/utils/retry.d.ts.map +1 -0
  126. package/dist/src/utils/retry.js +33 -0
  127. package/dist/src/utils/retry.js.map +1 -0
  128. package/dist/src/visual/comparator.d.ts +51 -0
  129. package/dist/src/visual/comparator.d.ts.map +1 -0
  130. package/dist/src/visual/comparator.js +119 -0
  131. package/dist/src/visual/comparator.js.map +1 -0
  132. package/dist/src/visual/layout-analyzer.d.ts +64 -0
  133. package/dist/src/visual/layout-analyzer.d.ts.map +1 -0
  134. package/dist/src/visual/layout-analyzer.js +198 -0
  135. package/dist/src/visual/layout-analyzer.js.map +1 -0
  136. package/dist/src/visual/screenshot.d.ts +17 -0
  137. package/dist/src/visual/screenshot.d.ts.map +1 -0
  138. package/dist/src/visual/screenshot.js +39 -0
  139. package/dist/src/visual/screenshot.js.map +1 -0
  140. package/docs/figma-workflow.md +289 -0
  141. package/docs/setup-guide.md +360 -0
  142. package/docs/tool-reference.md +622 -0
  143. package/package.json +57 -0
  144. package/src/config/devlens-config.ts +76 -0
  145. package/src/index.ts +5 -0
  146. package/src/metro/cdp-client.ts +160 -0
  147. package/src/metro/log-collector.ts +137 -0
  148. package/src/metro/metro-bridge.ts +307 -0
  149. package/src/metro/network-inspector.ts +134 -0
  150. package/src/platform/android/adb.ts +200 -0
  151. package/src/platform/android/android-device.ts +116 -0
  152. package/src/platform/android/ui-automator.ts +141 -0
  153. package/src/platform/device-manager.ts +229 -0
  154. package/src/platform/device.ts +110 -0
  155. package/src/platform/ios/accessibility.ts +189 -0
  156. package/src/platform/ios/ios-device.ts +116 -0
  157. package/src/platform/ios/simctl.ts +244 -0
  158. package/src/server.ts +228 -0
  159. package/src/snapshot/formatter.ts +102 -0
  160. package/src/snapshot/ref-registry.ts +230 -0
  161. package/src/snapshot/snapshot-differ.ts +220 -0
  162. package/src/tools/app-tools.ts +111 -0
  163. package/src/tools/device-tools.ts +96 -0
  164. package/src/tools/ds-tools.ts +395 -0
  165. package/src/tools/interaction-tools.ts +467 -0
  166. package/src/tools/metro-tools.ts +320 -0
  167. package/src/tools/navigation-tools.ts +71 -0
  168. package/src/tools/screenshot-tools.ts +698 -0
  169. package/src/tools/snapshot-tools.ts +585 -0
  170. package/src/utils/image-preprocess.ts +430 -0
  171. package/src/utils/retry.ts +51 -0
  172. package/src/visual/comparator.ts +191 -0
  173. package/src/visual/layout-analyzer.ts +283 -0
  174. package/src/visual/screenshot.ts +49 -0
  175. package/tsconfig.json +20 -0
@@ -0,0 +1,244 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ /**
7
+ * Wrapper around `xcrun simctl` for iOS Simulator interaction.
8
+ */
9
+ export class Simctl {
10
+ constructor(private deviceId: string) {}
11
+
12
+ /** Execute a simctl command */
13
+ async exec(...args: string[]): Promise<string> {
14
+ const { stdout } = await execFileAsync(
15
+ "xcrun",
16
+ ["simctl", ...args],
17
+ { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }
18
+ );
19
+ return stdout;
20
+ }
21
+
22
+ /** Take a screenshot and return PNG buffer */
23
+ async screenshot(): Promise<Buffer> {
24
+ const tmpPath = `/tmp/devlens-screenshot-${Date.now()}.png`;
25
+ await this.exec("io", this.deviceId, "screenshot", tmpPath);
26
+
27
+ const { readFile, unlink } = await import("fs/promises");
28
+ const buffer = await readFile(tmpPath);
29
+ unlink(tmpPath).catch(() => {});
30
+ return buffer;
31
+ }
32
+
33
+ /** Get the accessibility tree using simctl's accessibility audit */
34
+ async getAccessibilityTree(): Promise<string> {
35
+ // Use simctl ui to get the accessibility hierarchy
36
+ try {
37
+ const output = await this.exec(
38
+ "ui",
39
+ this.deviceId,
40
+ "accessibility",
41
+ );
42
+ return output;
43
+ } catch {
44
+ // Fallback: use the description command for view hierarchy
45
+ return this.getViewHierarchyViaXctest();
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Fallback: Get view hierarchy by running a small XCTest snippet.
51
+ * This uses `simctl launch` with a debugger approach, but the simplest
52
+ * method is to use the accessibility description from the simulator.
53
+ */
54
+ private async getViewHierarchyViaXctest(): Promise<string> {
55
+ // Use the simctl subprocess to query the UI
56
+ // We'll use `simctl ui <device> describe` to get the UI hierarchy
57
+ try {
58
+ const output = await this.exec("ui", this.deviceId, "describe");
59
+ return output;
60
+ } catch {
61
+ throw new Error(
62
+ "Could not extract accessibility tree from iOS Simulator. " +
63
+ "Ensure the simulator is booted and has an app running."
64
+ );
65
+ }
66
+ }
67
+
68
+ /** Get device info from simctl */
69
+ async getDeviceInfo(): Promise<{
70
+ name: string;
71
+ runtime: string;
72
+ state: string;
73
+ }> {
74
+ const { stdout } = await execFileAsync("xcrun", [
75
+ "simctl",
76
+ "list",
77
+ "devices",
78
+ "--json",
79
+ ]);
80
+ const data = JSON.parse(stdout);
81
+
82
+ for (const [runtime, devices] of Object.entries(
83
+ data.devices as Record<string, Array<{ udid: string; name: string; state: string }>>
84
+ )) {
85
+ for (const device of devices) {
86
+ if (device.udid === this.deviceId) {
87
+ return {
88
+ name: device.name,
89
+ runtime: runtime
90
+ .replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "")
91
+ .replace(/-/g, "."),
92
+ state: device.state,
93
+ };
94
+ }
95
+ }
96
+ }
97
+ throw new Error(`Device ${this.deviceId} not found in simctl list`);
98
+ }
99
+
100
+ /** Get screen size of the simulator */
101
+ async getScreenSize(): Promise<{ width: number; height: number }> {
102
+ // Take a tiny screenshot and check its dimensions via sharp
103
+ const tmpPath = `/tmp/devlens-size-check-${Date.now()}.png`;
104
+ await this.exec("io", this.deviceId, "screenshot", tmpPath);
105
+
106
+ try {
107
+ const sharp = (await import("sharp")).default;
108
+ const metadata = await sharp(tmpPath).metadata();
109
+ const { unlink } = await import("fs/promises");
110
+ unlink(tmpPath).catch(() => {});
111
+
112
+ return {
113
+ width: metadata.width || 390,
114
+ height: metadata.height || 844,
115
+ };
116
+ } catch {
117
+ // Fallback defaults (iPhone 14)
118
+ return { width: 390, height: 844 };
119
+ }
120
+ }
121
+
122
+ /** Tap at coordinates using simctl */
123
+ async tap(x: number, y: number): Promise<void> {
124
+ // simctl doesn't have a direct tap command, so we use the io command
125
+ // with the AppleScript approach or the simctl interact command
126
+ try {
127
+ await this.exec(
128
+ "io",
129
+ this.deviceId,
130
+ "tap",
131
+ String(Math.round(x)),
132
+ String(Math.round(y))
133
+ );
134
+ } catch {
135
+ // Fallback: use AppleScript to tap on the simulator
136
+ await execFileAsync("osascript", [
137
+ "-e",
138
+ `tell application "Simulator" to activate`,
139
+ ]);
140
+ // Use cliclick or similar for coordinate-based tapping
141
+ throw new Error(
142
+ "Direct tap not supported on this simctl version. Consider using a newer Xcode."
143
+ );
144
+ }
145
+ }
146
+
147
+ /** Swipe on the simulator */
148
+ async swipe(
149
+ startX: number,
150
+ startY: number,
151
+ endX: number,
152
+ endY: number
153
+ ): Promise<void> {
154
+ try {
155
+ await this.exec(
156
+ "io",
157
+ this.deviceId,
158
+ "swipe",
159
+ String(Math.round(startX)),
160
+ String(Math.round(startY)),
161
+ String(Math.round(endX)),
162
+ String(Math.round(endY))
163
+ );
164
+ } catch {
165
+ throw new Error("Swipe not supported on this simctl version.");
166
+ }
167
+ }
168
+
169
+ /** Type text using simctl keyboard input */
170
+ async typeText(text: string): Promise<void> {
171
+ await this.exec("io", this.deviceId, "keyboard", "type", text);
172
+ }
173
+
174
+ /** Press a key using simctl */
175
+ async pressKey(key: string): Promise<void> {
176
+ await this.exec("io", this.deviceId, "keyboard", "key", key);
177
+ }
178
+
179
+ /** Launch an app */
180
+ async launchApp(bundleId: string): Promise<void> {
181
+ await this.exec("launch", this.deviceId, bundleId);
182
+ }
183
+
184
+ /** Terminate an app */
185
+ async terminateApp(bundleId: string): Promise<void> {
186
+ await this.exec("terminate", this.deviceId, bundleId);
187
+ }
188
+
189
+ /** Install an app (.app bundle or .ipa) */
190
+ async installApp(path: string): Promise<void> {
191
+ await this.exec("install", this.deviceId, path);
192
+ }
193
+
194
+ /** List installed apps */
195
+ async listApps(): Promise<Array<{ bundleId: string; name: string }>> {
196
+ try {
197
+ const output = await this.exec(
198
+ "listapps",
199
+ this.deviceId,
200
+ );
201
+ // Parse the plist/json output
202
+ const apps: Array<{ bundleId: string; name: string }> = [];
203
+ const bundleIdMatches = output.matchAll(
204
+ /"CFBundleIdentifier"\s*=\s*"([^"]+)"/g
205
+ );
206
+ const nameMatches = output.matchAll(
207
+ /"CFBundleDisplayName"\s*=\s*"([^"]+)"/g
208
+ );
209
+ const bundleIds = [...bundleIdMatches].map((m) => m[1]);
210
+ const names = [...nameMatches].map((m) => m[1]);
211
+
212
+ for (let i = 0; i < bundleIds.length; i++) {
213
+ apps.push({
214
+ bundleId: bundleIds[i],
215
+ name: names[i] || bundleIds[i],
216
+ });
217
+ }
218
+ return apps;
219
+ } catch {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ /** Open a URL */
225
+ async openUrl(url: string): Promise<void> {
226
+ await this.exec("openurl", this.deviceId, url);
227
+ }
228
+
229
+ /** Set device orientation */
230
+ async setOrientation(orientation: "portrait" | "landscape"): Promise<void> {
231
+ // simctl doesn't have a direct orientation command
232
+ // Use the Simulator menu via AppleScript
233
+ const rotation = orientation === "portrait" ? "0" : "90";
234
+ try {
235
+ await execFileAsync("osascript", [
236
+ "-e",
237
+ `tell application "System Events" to tell process "Simulator" to ` +
238
+ `click menu item "Rotate ${orientation === "landscape" ? "Right" : "Left"}" of menu "Device" of menu bar 1`,
239
+ ]);
240
+ } catch {
241
+ // Orientation setting may not be available in all environments
242
+ }
243
+ }
244
+ }
package/src/server.ts ADDED
@@ -0,0 +1,228 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+ import { DeviceManager } from "./platform/device-manager.js";
8
+ import { RefRegistry } from "./snapshot/ref-registry.js";
9
+ import { loadDevLensConfig } from "./config/devlens-config.js";
10
+ import { dsToolSchemas, createDsToolHandlers } from "./tools/ds-tools.js";
11
+ import {
12
+ deviceToolSchemas,
13
+ createDeviceToolHandlers,
14
+ } from "./tools/device-tools.js";
15
+ import {
16
+ snapshotToolSchemas,
17
+ createSnapshotToolHandlers,
18
+ } from "./tools/snapshot-tools.js";
19
+ import {
20
+ interactionToolSchemas,
21
+ createInteractionToolHandlers,
22
+ } from "./tools/interaction-tools.js";
23
+ import {
24
+ screenshotToolSchemas,
25
+ createScreenshotToolHandlers,
26
+ } from "./tools/screenshot-tools.js";
27
+ import {
28
+ appToolSchemas,
29
+ createAppToolHandlers,
30
+ } from "./tools/app-tools.js";
31
+ import {
32
+ metroToolSchemas,
33
+ createMetroToolHandlers,
34
+ } from "./tools/metro-tools.js";
35
+ import {
36
+ navigationToolSchemas,
37
+ createNavigationToolHandlers,
38
+ } from "./tools/navigation-tools.js";
39
+
40
+ export async function startServer(): Promise<Server> {
41
+ const server = new Server(
42
+ {
43
+ name: "devlens-mcp",
44
+ version: "0.3.0",
45
+ },
46
+ {
47
+ capabilities: {
48
+ tools: {},
49
+ },
50
+ }
51
+ );
52
+
53
+ // Load config (from DEVLENS_CONFIG env var or ./devlens.config.json)
54
+ const devlensConfig = await loadDevLensConfig();
55
+
56
+ // Shared state
57
+ const deviceManager = new DeviceManager();
58
+ const refRegistry = new RefRegistry();
59
+ const metroPort = parseInt(process.env.METRO_PORT || "8081", 10);
60
+
61
+ // Create tool handlers
62
+ const dsHandlers = createDsToolHandlers(devlensConfig.designSystem);
63
+ const deviceHandlers = createDeviceToolHandlers(deviceManager);
64
+ const { handlers: snapshotHandlers, resetPreviousSnapshot } =
65
+ createSnapshotToolHandlers(deviceManager, refRegistry);
66
+ const interactionHandlers = createInteractionToolHandlers(
67
+ deviceManager,
68
+ refRegistry,
69
+ resetPreviousSnapshot
70
+ );
71
+ const screenshotHandlers = createScreenshotToolHandlers(
72
+ deviceManager,
73
+ refRegistry
74
+ );
75
+ const appHandlers = createAppToolHandlers(deviceManager);
76
+ const metroHandlers = createMetroToolHandlers(metroPort, deviceManager);
77
+ const navigationHandlers = createNavigationToolHandlers(
78
+ deviceManager,
79
+ refRegistry,
80
+ resetPreviousSnapshot
81
+ );
82
+
83
+ // Merge all tool schemas
84
+ const allSchemas: Record<string, { description: string; parameters: any }> = {
85
+ ...dsToolSchemas,
86
+ ...deviceToolSchemas,
87
+ ...snapshotToolSchemas,
88
+ ...interactionToolSchemas,
89
+ ...screenshotToolSchemas,
90
+ ...appToolSchemas,
91
+ ...metroToolSchemas,
92
+ ...navigationToolSchemas,
93
+ };
94
+
95
+ // Merge all handlers
96
+ const allHandlers: Record<string, (params: any) => Promise<any>> = {
97
+ ...dsHandlers,
98
+ ...deviceHandlers,
99
+ ...snapshotHandlers,
100
+ ...interactionHandlers,
101
+ ...screenshotHandlers,
102
+ ...appHandlers,
103
+ ...metroHandlers,
104
+ ...navigationHandlers,
105
+ };
106
+
107
+ // Register tools/list handler
108
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
109
+ return {
110
+ tools: Object.entries(allSchemas).map(([name, schema]) => ({
111
+ name,
112
+ description: schema.description,
113
+ inputSchema: zodToJsonSchema(schema.parameters),
114
+ })),
115
+ };
116
+ });
117
+
118
+ // Register tools/call handler
119
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
120
+ const { name, arguments: args } = request.params;
121
+
122
+ const handler = allHandlers[name];
123
+ if (!handler) {
124
+ return {
125
+ content: [
126
+ {
127
+ type: "text" as const,
128
+ text: `Unknown tool: ${name}`,
129
+ },
130
+ ],
131
+ isError: true,
132
+ };
133
+ }
134
+
135
+ try {
136
+ // Validate params with Zod schema
137
+ const schema = allSchemas[name];
138
+ const validatedArgs = schema.parameters.parse(args || {});
139
+ return await handler(validatedArgs);
140
+ } catch (error: any) {
141
+ return {
142
+ content: [
143
+ {
144
+ type: "text" as const,
145
+ text: `Error: ${error.message || String(error)}`,
146
+ },
147
+ ],
148
+ isError: true,
149
+ };
150
+ }
151
+ });
152
+
153
+ // Connect via stdio transport
154
+ const transport = new StdioServerTransport();
155
+ await server.connect(transport);
156
+
157
+ return server;
158
+ }
159
+
160
+ /**
161
+ * Convert a Zod schema to JSON Schema for MCP tool registration.
162
+ * This is a simplified converter that handles the common cases.
163
+ */
164
+ function zodToJsonSchema(schema: any): Record<string, any> {
165
+ // Use Zod's built-in JSON schema support if available
166
+ if (schema._def) {
167
+ return convertZodDef(schema._def);
168
+ }
169
+ return { type: "object", properties: {} };
170
+ }
171
+
172
+ function convertZodDef(def: any): Record<string, any> {
173
+ switch (def.typeName) {
174
+ case "ZodObject": {
175
+ const properties: Record<string, any> = {};
176
+ const required: string[] = [];
177
+
178
+ for (const [key, value] of Object.entries(def.shape()) as Array<
179
+ [string, any]
180
+ >) {
181
+ const propDef = value._def;
182
+ if (propDef.typeName === "ZodOptional" || propDef.typeName === "ZodDefault") {
183
+ properties[key] = convertZodDef(
184
+ propDef.typeName === "ZodDefault" ? propDef.innerType._def : propDef.innerType._def
185
+ );
186
+ } else {
187
+ properties[key] = convertZodDef(propDef);
188
+ required.push(key);
189
+ }
190
+ // Add description from Zod describe()
191
+ if (propDef.description) {
192
+ properties[key].description = propDef.description;
193
+ } else if (value.description) {
194
+ properties[key].description = value.description;
195
+ }
196
+ }
197
+
198
+ return {
199
+ type: "object",
200
+ properties,
201
+ ...(required.length > 0 ? { required } : {}),
202
+ };
203
+ }
204
+ case "ZodString":
205
+ return { type: "string", ...(def.description ? { description: def.description } : {}) };
206
+ case "ZodNumber":
207
+ return { type: "number", ...(def.description ? { description: def.description } : {}) };
208
+ case "ZodBoolean":
209
+ return { type: "boolean", ...(def.description ? { description: def.description } : {}) };
210
+ case "ZodEnum":
211
+ return { type: "string", enum: def.values, ...(def.description ? { description: def.description } : {}) };
212
+ case "ZodArray":
213
+ return {
214
+ type: "array",
215
+ items: convertZodDef(def.type._def),
216
+ ...(def.description ? { description: def.description } : {}),
217
+ };
218
+ case "ZodDefault":
219
+ return {
220
+ ...convertZodDef(def.innerType._def),
221
+ default: def.defaultValue(),
222
+ };
223
+ case "ZodOptional":
224
+ return convertZodDef(def.innerType._def);
225
+ default:
226
+ return {};
227
+ }
228
+ }
@@ -0,0 +1,102 @@
1
+ import type { SnapshotNode } from "../platform/device.js";
2
+
3
+ /**
4
+ * Formats a SnapshotNode tree into a YAML-like text representation
5
+ * that LLMs can easily parse and reason about.
6
+ *
7
+ * Output format (inspired by Playwright MCP):
8
+ *
9
+ * - FrameLayout
10
+ * - LinearLayout
11
+ * - TextView "Hello World" [ref=e1]
12
+ * - Button "Submit" [ref=e2]
13
+ * - EditText "Email" [ref=e3] [focused]
14
+ * - RecyclerView [ref=e4]
15
+ * - CardView
16
+ * - TextView "Item 1" [ref=e5]
17
+ */
18
+ export function formatSnapshot(
19
+ root: SnapshotNode,
20
+ nodeToRef: Map<SnapshotNode, string>
21
+ ): string {
22
+ const lines: string[] = [];
23
+ formatNode(root, nodeToRef, 0, lines);
24
+ return lines.join("\n");
25
+ }
26
+
27
+ function formatNode(
28
+ node: SnapshotNode,
29
+ nodeToRef: Map<SnapshotNode, string>,
30
+ depth: number,
31
+ lines: string[]
32
+ ): void {
33
+ const indent = " ".repeat(depth);
34
+ const parts: string[] = [];
35
+
36
+ // Type
37
+ parts.push(node.type);
38
+
39
+ // Text content
40
+ if (node.text) {
41
+ parts.push(`"${truncate(node.text, 60)}"`);
42
+ }
43
+
44
+ // Accessibility label (if different from text)
45
+ if (node.label && node.label !== node.text) {
46
+ parts.push(`label="${truncate(node.label, 40)}"`);
47
+ }
48
+
49
+ // Annotations
50
+ const annotations: string[] = [];
51
+
52
+ // Ref
53
+ const ref = nodeToRef.get(node);
54
+ if (ref) {
55
+ annotations.push(`ref=${ref}`);
56
+ }
57
+
58
+ // State annotations
59
+ if (node.focused) annotations.push("focused");
60
+ if (node.value) annotations.push(`value="${node.value}"`);
61
+ if (node.enabled === false) annotations.push("disabled");
62
+ if (node.resourceId) {
63
+ const shortId = node.resourceId.includes("/")
64
+ ? node.resourceId.split("/").pop()!
65
+ : node.resourceId;
66
+ annotations.push(`id="${shortId}"`);
67
+ }
68
+
69
+ if (annotations.length > 0) {
70
+ parts.push(`[${annotations.join("] [")}]`);
71
+ }
72
+
73
+ // Build the line
74
+ const line = `${indent}- ${parts.join(" ")}`;
75
+
76
+ // Only include nodes that have refs, text, labels, or children with refs
77
+ if (ref || node.text || node.label || hasRefInSubtree(node, nodeToRef)) {
78
+ lines.push(line);
79
+ }
80
+
81
+ // Recurse into children
82
+ for (const child of node.children) {
83
+ formatNode(child, nodeToRef, depth + 1, lines);
84
+ }
85
+ }
86
+
87
+ /** Check if any descendant has a ref assigned */
88
+ function hasRefInSubtree(
89
+ node: SnapshotNode,
90
+ nodeToRef: Map<SnapshotNode, string>
91
+ ): boolean {
92
+ for (const child of node.children) {
93
+ if (nodeToRef.has(child)) return true;
94
+ if (hasRefInSubtree(child, nodeToRef)) return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ function truncate(str: string, maxLen: number): string {
100
+ if (str.length <= maxLen) return str;
101
+ return str.slice(0, maxLen - 3) + "...";
102
+ }