mcp-maestro-mobile-ai 1.1.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.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Validation Tools
3
+ * Validate Maestro YAML flows before execution
4
+ */
5
+
6
+ import yaml from 'js-yaml';
7
+ import { logger } from '../utils/logger.js';
8
+
9
+ /**
10
+ * Validate Maestro YAML syntax and structure
11
+ */
12
+ export async function validateMaestroYaml(yamlContent) {
13
+ const errors = [];
14
+ const warnings = [];
15
+
16
+ try {
17
+ // Check if content is empty
18
+ if (!yamlContent || yamlContent.trim().length === 0) {
19
+ return {
20
+ content: [
21
+ {
22
+ type: 'text',
23
+ text: JSON.stringify({
24
+ valid: false,
25
+ errors: ['YAML content is empty'],
26
+ warnings: [],
27
+ }),
28
+ },
29
+ ],
30
+ };
31
+ }
32
+
33
+ // Split by document separator (---)
34
+ const parts = yamlContent.split(/^---$/m);
35
+ let configPart = '';
36
+ let flowPart = '';
37
+
38
+ if (parts.length >= 2) {
39
+ configPart = parts[0];
40
+ flowPart = parts.slice(1).join('---');
41
+ } else {
42
+ flowPart = yamlContent;
43
+ }
44
+
45
+ // Parse config section
46
+ let config = {};
47
+ if (configPart.trim()) {
48
+ try {
49
+ config = yaml.load(configPart) || {};
50
+ } catch (e) {
51
+ errors.push(`Invalid YAML in config section: ${e.message}`);
52
+ }
53
+ }
54
+
55
+ // Check for appId
56
+ if (!config.appId) {
57
+ errors.push('Missing required "appId" in config section. Example: appId: com.example.app');
58
+ }
59
+
60
+ // Parse flow section
61
+ let flow = [];
62
+ if (flowPart.trim()) {
63
+ try {
64
+ flow = yaml.load(flowPart) || [];
65
+ } catch (e) {
66
+ errors.push(`Invalid YAML in flow section: ${e.message}`);
67
+ }
68
+ }
69
+
70
+ // Validate flow is an array
71
+ if (!Array.isArray(flow)) {
72
+ errors.push('Flow section must be an array of steps');
73
+ } else if (flow.length === 0) {
74
+ errors.push('Flow has no steps defined');
75
+ } else {
76
+ // Validate each step
77
+ flow.forEach((step, index) => {
78
+ validateStep(step, index, errors, warnings);
79
+ });
80
+ }
81
+
82
+ const isValid = errors.length === 0;
83
+
84
+ logger.info(`YAML validation: ${isValid ? 'passed' : 'failed'}`, {
85
+ errors: errors.length,
86
+ warnings: warnings.length,
87
+ });
88
+
89
+ return {
90
+ content: [
91
+ {
92
+ type: 'text',
93
+ text: JSON.stringify({
94
+ valid: isValid,
95
+ errors,
96
+ warnings,
97
+ config: isValid ? config : undefined,
98
+ stepCount: Array.isArray(flow) ? flow.length : 0,
99
+ }),
100
+ },
101
+ ],
102
+ };
103
+ } catch (error) {
104
+ logger.error('Validation error', { error: error.message });
105
+ return {
106
+ content: [
107
+ {
108
+ type: 'text',
109
+ text: JSON.stringify({
110
+ valid: false,
111
+ errors: [`Validation error: ${error.message}`],
112
+ warnings: [],
113
+ }),
114
+ },
115
+ ],
116
+ };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Validate a single Maestro step
122
+ */
123
+ function validateStep(step, index, errors, warnings) {
124
+ if (typeof step === 'string') {
125
+ // Simple command like "launchApp" or "- back"
126
+ const validSimpleCommands = [
127
+ 'launchApp',
128
+ 'stopApp',
129
+ 'clearState',
130
+ 'clearKeychain',
131
+ 'back',
132
+ 'home',
133
+ 'hideKeyboard',
134
+ 'scroll',
135
+ 'scrollUntilVisible',
136
+ ];
137
+
138
+ if (!validSimpleCommands.includes(step)) {
139
+ warnings.push(`Step ${index + 1}: "${step}" may not be a valid simple command`);
140
+ }
141
+ return;
142
+ }
143
+
144
+ if (typeof step !== 'object' || step === null) {
145
+ errors.push(`Step ${index + 1}: Invalid step format`);
146
+ return;
147
+ }
148
+
149
+ // Get the command name (first key)
150
+ const keys = Object.keys(step);
151
+ if (keys.length === 0) {
152
+ errors.push(`Step ${index + 1}: Empty step object`);
153
+ return;
154
+ }
155
+
156
+ const command = keys[0];
157
+ const value = step[command];
158
+
159
+ // Validate known commands
160
+ const validCommands = [
161
+ 'launchApp',
162
+ 'stopApp',
163
+ 'clearState',
164
+ 'tapOn',
165
+ 'longPressOn',
166
+ 'doubleTapOn',
167
+ 'inputText',
168
+ 'eraseText',
169
+ 'pressKey',
170
+ 'swipe',
171
+ 'scroll',
172
+ 'scrollUntilVisible',
173
+ 'assertVisible',
174
+ 'assertNotVisible',
175
+ 'waitForAnimationToEnd',
176
+ 'extendedWaitUntil',
177
+ 'takeScreenshot',
178
+ 'runFlow',
179
+ 'runScript',
180
+ 'evalScript',
181
+ 'assertTrue',
182
+ 'back',
183
+ 'home',
184
+ 'hideKeyboard',
185
+ 'openLink',
186
+ 'repeat',
187
+ 'copyTextFrom',
188
+ 'pasteText',
189
+ ];
190
+
191
+ if (!validCommands.includes(command)) {
192
+ warnings.push(`Step ${index + 1}: "${command}" is not a recognized Maestro command`);
193
+ }
194
+
195
+ // Specific validations
196
+ if (command === 'tapOn' || command === 'assertVisible') {
197
+ if (typeof value !== 'string' && typeof value !== 'object') {
198
+ errors.push(`Step ${index + 1}: "${command}" requires a string or object selector`);
199
+ }
200
+ }
201
+
202
+ if (command === 'inputText') {
203
+ if (typeof value !== 'string') {
204
+ errors.push(`Step ${index + 1}: "inputText" requires a string value`);
205
+ }
206
+ }
207
+
208
+ if (command === 'extendedWaitUntil') {
209
+ if (typeof value !== 'object') {
210
+ errors.push(`Step ${index + 1}: "extendedWaitUntil" requires an object with "visible" and optional "timeout"`);
211
+ } else if (!value.visible) {
212
+ errors.push(`Step ${index + 1}: "extendedWaitUntil" requires a "visible" property`);
213
+ }
214
+ }
215
+ }
216
+
217
+ export default {
218
+ validateMaestroYaml,
219
+ };
220
+
@@ -0,0 +1,295 @@
1
+ /**
2
+ * App Context Manager
3
+ *
4
+ * Provides context/training data to help AI generate better YAML.
5
+ * Stores information about app elements, successful flows, and patterns.
6
+ */
7
+
8
+ import fs from "fs/promises";
9
+ import path from "path";
10
+ import os from "os";
11
+ import { logger } from "./logger.js";
12
+
13
+ // Context storage location
14
+ const USER_HOME = os.homedir();
15
+ const CONTEXT_DIR = path.join(USER_HOME, ".maestro-mcp", "context");
16
+
17
+ /**
18
+ * Initialize context directory
19
+ */
20
+ async function ensureContextDir() {
21
+ await fs.mkdir(CONTEXT_DIR, { recursive: true });
22
+ }
23
+
24
+ /**
25
+ * Get the context file path for an app
26
+ */
27
+ function getContextPath(appId) {
28
+ // Sanitize appId for filename
29
+ const safeAppId = appId.replace(/[^a-zA-Z0-9.-]/g, "_");
30
+ return path.join(CONTEXT_DIR, `${safeAppId}.json`);
31
+ }
32
+
33
+ /**
34
+ * Load app context
35
+ */
36
+ export async function loadAppContext(appId) {
37
+ if (!appId) {
38
+ return { elements: {}, flows: [], patterns: [] };
39
+ }
40
+
41
+ try {
42
+ await ensureContextDir();
43
+ const contextPath = getContextPath(appId);
44
+ const content = await fs.readFile(contextPath, "utf8");
45
+ return JSON.parse(content);
46
+ } catch (error) {
47
+ // No context exists yet
48
+ return {
49
+ appId,
50
+ elements: {},
51
+ screens: {},
52
+ flows: [],
53
+ patterns: [],
54
+ createdAt: new Date().toISOString(),
55
+ updatedAt: new Date().toISOString(),
56
+ };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Save app context
62
+ */
63
+ export async function saveAppContext(appId, context) {
64
+ await ensureContextDir();
65
+ const contextPath = getContextPath(appId);
66
+ context.updatedAt = new Date().toISOString();
67
+ await fs.writeFile(contextPath, JSON.stringify(context, null, 2), "utf8");
68
+ logger.info(`App context saved: ${appId}`);
69
+ return { success: true, path: contextPath };
70
+ }
71
+
72
+ /**
73
+ * Register UI elements for an app
74
+ * These are the testIDs, accessibilityLabels, and text that can be used
75
+ */
76
+ export async function registerElements(appId, elements) {
77
+ const context = await loadAppContext(appId);
78
+
79
+ // Merge new elements
80
+ context.elements = {
81
+ ...context.elements,
82
+ ...elements,
83
+ };
84
+
85
+ await saveAppContext(appId, context);
86
+
87
+ return {
88
+ success: true,
89
+ elementsCount: Object.keys(context.elements).length,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Register screen structure
95
+ * Defines what elements exist on each screen
96
+ */
97
+ export async function registerScreen(appId, screenName, screenData) {
98
+ const context = await loadAppContext(appId);
99
+
100
+ context.screens = context.screens || {};
101
+ context.screens[screenName] = {
102
+ ...screenData,
103
+ updatedAt: new Date().toISOString(),
104
+ };
105
+
106
+ await saveAppContext(appId, context);
107
+
108
+ return {
109
+ success: true,
110
+ screen: screenName,
111
+ screensCount: Object.keys(context.screens).length,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Save a successful flow as a pattern for future reference
117
+ */
118
+ export async function saveSuccessfulFlow(appId, flowName, yamlContent, description) {
119
+ const context = await loadAppContext(appId);
120
+
121
+ context.flows = context.flows || [];
122
+
123
+ // Remove existing flow with same name
124
+ context.flows = context.flows.filter(f => f.name !== flowName);
125
+
126
+ // Add new flow
127
+ context.flows.push({
128
+ name: flowName,
129
+ description: description || "",
130
+ yaml: yamlContent,
131
+ savedAt: new Date().toISOString(),
132
+ });
133
+
134
+ // Keep only last 50 flows
135
+ if (context.flows.length > 50) {
136
+ context.flows = context.flows.slice(-50);
137
+ }
138
+
139
+ await saveAppContext(appId, context);
140
+
141
+ return {
142
+ success: true,
143
+ flowName,
144
+ totalFlows: context.flows.length,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Get all saved flows for an app
150
+ */
151
+ export async function getSavedFlows(appId) {
152
+ const context = await loadAppContext(appId);
153
+ return {
154
+ success: true,
155
+ flows: context.flows || [],
156
+ count: (context.flows || []).length,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Delete a saved flow
162
+ */
163
+ export async function deleteFlow(appId, flowName) {
164
+ const context = await loadAppContext(appId);
165
+
166
+ const before = (context.flows || []).length;
167
+ context.flows = (context.flows || []).filter(f => f.name !== flowName);
168
+ const after = context.flows.length;
169
+
170
+ await saveAppContext(appId, context);
171
+
172
+ return {
173
+ success: before !== after,
174
+ deleted: before !== after,
175
+ message: before !== after ? `Flow "${flowName}" deleted` : `Flow "${flowName}" not found`,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Generate AI prompt context from app context
181
+ * This is the key function that provides training data to AI
182
+ */
183
+ export async function generateAIPromptContext(appId) {
184
+ const context = await loadAppContext(appId);
185
+
186
+ let promptContext = `## App Context for ${appId}\n\n`;
187
+
188
+ // Add elements information
189
+ if (Object.keys(context.elements || {}).length > 0) {
190
+ promptContext += `### Available UI Elements (use these exact IDs/labels):\n\n`;
191
+ for (const [key, element] of Object.entries(context.elements)) {
192
+ promptContext += `- **${key}**: `;
193
+ if (element.testId) promptContext += `testID="${element.testId}" `;
194
+ if (element.accessibilityLabel) promptContext += `accessibilityLabel="${element.accessibilityLabel}" `;
195
+ if (element.text) promptContext += `text="${element.text}" `;
196
+ if (element.type) promptContext += `(${element.type})`;
197
+ if (element.description) promptContext += ` - ${element.description}`;
198
+ promptContext += `\n`;
199
+ }
200
+ promptContext += `\n`;
201
+ }
202
+
203
+ // Add screen information
204
+ if (Object.keys(context.screens || {}).length > 0) {
205
+ promptContext += `### Screen Structure:\n\n`;
206
+ for (const [screenName, screen] of Object.entries(context.screens)) {
207
+ promptContext += `#### ${screenName}\n`;
208
+ if (screen.description) promptContext += `${screen.description}\n`;
209
+ if (screen.elements && screen.elements.length > 0) {
210
+ promptContext += `Elements: ${screen.elements.join(", ")}\n`;
211
+ }
212
+ if (screen.actions && screen.actions.length > 0) {
213
+ promptContext += `Available actions: ${screen.actions.join(", ")}\n`;
214
+ }
215
+ promptContext += `\n`;
216
+ }
217
+ }
218
+
219
+ // Add example flows (patterns)
220
+ if ((context.flows || []).length > 0) {
221
+ promptContext += `### Example Flows (reference these patterns):\n\n`;
222
+ // Show last 5 flows as examples
223
+ const recentFlows = context.flows.slice(-5);
224
+ for (const flow of recentFlows) {
225
+ promptContext += `#### ${flow.name}\n`;
226
+ if (flow.description) promptContext += `${flow.description}\n`;
227
+ promptContext += `\`\`\`yaml\n${flow.yaml}\n\`\`\`\n\n`;
228
+ }
229
+ }
230
+
231
+ // Add general instructions
232
+ promptContext += `### Generation Instructions:\n`;
233
+ promptContext += `- Use the exact testID or accessibilityLabel values shown above\n`;
234
+ promptContext += `- Follow the patterns from example flows\n`;
235
+ promptContext += `- Add appropriate waits between actions\n`;
236
+ promptContext += `- Use assertVisible to verify screens before interacting\n`;
237
+
238
+ return {
239
+ success: true,
240
+ context: promptContext,
241
+ hasElements: Object.keys(context.elements || {}).length > 0,
242
+ hasScreens: Object.keys(context.screens || {}).length > 0,
243
+ hasFlows: (context.flows || []).length > 0,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Clear all context for an app
249
+ */
250
+ export async function clearAppContext(appId) {
251
+ try {
252
+ await ensureContextDir();
253
+ const contextPath = getContextPath(appId);
254
+ await fs.unlink(contextPath);
255
+ logger.info(`App context cleared: ${appId}`);
256
+ return { success: true, message: `Context for ${appId} cleared` };
257
+ } catch (error) {
258
+ return { success: false, error: error.message };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * List all apps with saved context
264
+ */
265
+ export async function listAppContexts() {
266
+ try {
267
+ await ensureContextDir();
268
+ const files = await fs.readdir(CONTEXT_DIR);
269
+ const contexts = files
270
+ .filter(f => f.endsWith(".json"))
271
+ .map(f => f.replace(".json", "").replace(/_/g, "."));
272
+
273
+ return {
274
+ success: true,
275
+ apps: contexts,
276
+ count: contexts.length,
277
+ };
278
+ } catch (error) {
279
+ return { success: true, apps: [], count: 0 };
280
+ }
281
+ }
282
+
283
+ export default {
284
+ loadAppContext,
285
+ saveAppContext,
286
+ registerElements,
287
+ registerScreen,
288
+ saveSuccessfulFlow,
289
+ getSavedFlows,
290
+ deleteFlow,
291
+ generateAIPromptContext,
292
+ clearAppContext,
293
+ listAppContexts,
294
+ };
295
+
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Logger Utility for MCP Server
3
+ * Logs to file only (stderr is used by MCP protocol)
4
+ */
5
+
6
+ import winston from 'winston';
7
+ import path from 'path';
8
+ import fs from 'fs';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, join } from 'path';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ // Ensure logs directory exists
16
+ const logsDir = process.env.LOGS_DIR || join(__dirname, '../../../output/logs');
17
+ if (!fs.existsSync(logsDir)) {
18
+ fs.mkdirSync(logsDir, { recursive: true });
19
+ }
20
+
21
+ // File format for logs
22
+ const fileFormat = winston.format.combine(
23
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
24
+ winston.format.errors({ stack: true }),
25
+ winston.format.json()
26
+ );
27
+
28
+ // Create logger instance - FILE ONLY (MCP uses stdio)
29
+ export const logger = winston.createLogger({
30
+ level: process.env.LOG_LEVEL || 'info',
31
+ transports: [
32
+ // File transport for all logs
33
+ new winston.transports.File({
34
+ filename: path.join(logsDir, 'mcp-server.log'),
35
+ format: fileFormat,
36
+ maxsize: 5242880, // 5MB
37
+ maxFiles: 5,
38
+ }),
39
+
40
+ // Separate file for errors
41
+ new winston.transports.File({
42
+ filename: path.join(logsDir, 'error.log'),
43
+ format: fileFormat,
44
+ level: 'error',
45
+ maxsize: 5242880,
46
+ maxFiles: 5,
47
+ }),
48
+ ],
49
+ });
50
+
51
+ export default logger;
52
+