mcp-maestro-mobile-ai 1.4.0 → 1.6.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/CHANGELOG.md +101 -1
- package/package.json +6 -3
- package/src/mcp-server/index.js +339 -4
- package/src/mcp-server/schemas/toolSchemas.js +184 -0
- package/src/mcp-server/tools/contextTools.js +309 -2
- package/src/mcp-server/tools/runTools.js +409 -31
- package/src/mcp-server/utils/knownIssues.js +564 -0
- package/src/mcp-server/utils/promptAnalyzer.js +701 -0
- package/src/mcp-server/utils/yamlCache.js +381 -0
- package/src/mcp-server/utils/yamlGenerator.js +426 -0
- package/src/mcp-server/utils/yamlTemplate.js +303 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML Cache System
|
|
3
|
+
*
|
|
4
|
+
* Caches generated YAML files for faster execution of recurring test scenarios.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Prompt-based hashing for unique identification
|
|
8
|
+
* - Hidden internal storage (~/.maestro-mcp/cache/)
|
|
9
|
+
* - Automatic cache lookup on test execution
|
|
10
|
+
* - Save successful tests for future reuse
|
|
11
|
+
* - Invalidation when prompt changes
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from "crypto";
|
|
15
|
+
import fs from "fs/promises";
|
|
16
|
+
import { existsSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import os from "os";
|
|
19
|
+
import { logger } from "./logger.js";
|
|
20
|
+
|
|
21
|
+
// Cache directory (hidden from user, alongside other maestro-mcp files)
|
|
22
|
+
const CACHE_DIR = join(os.homedir(), ".maestro-mcp", "cache");
|
|
23
|
+
const CACHE_INDEX_FILE = join(CACHE_DIR, "index.json");
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generate a hash from the prompt for unique identification
|
|
27
|
+
* Normalizes the prompt to handle minor formatting differences
|
|
28
|
+
*/
|
|
29
|
+
export function generatePromptHash(prompt, appId = null) {
|
|
30
|
+
// Normalize the prompt: lowercase, trim, collapse whitespace
|
|
31
|
+
const normalized = prompt
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.trim()
|
|
34
|
+
.replace(/\s+/g, " ")
|
|
35
|
+
.replace(/['"]/g, ""); // Remove quotes for consistency
|
|
36
|
+
|
|
37
|
+
// Include appId in hash if provided
|
|
38
|
+
const hashInput = appId ? `${appId}::${normalized}` : normalized;
|
|
39
|
+
|
|
40
|
+
return createHash("sha256").update(hashInput).digest("hex").substring(0, 16); // Use first 16 chars for readability
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Initialize cache directory
|
|
45
|
+
*/
|
|
46
|
+
async function ensureCacheDir() {
|
|
47
|
+
try {
|
|
48
|
+
await fs.mkdir(CACHE_DIR, { recursive: true });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger.error("Failed to create cache directory", { error: error.message });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load the cache index
|
|
56
|
+
*/
|
|
57
|
+
async function loadCacheIndex() {
|
|
58
|
+
try {
|
|
59
|
+
await ensureCacheDir();
|
|
60
|
+
|
|
61
|
+
if (!existsSync(CACHE_INDEX_FILE)) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const content = await fs.readFile(CACHE_INDEX_FILE, "utf8");
|
|
66
|
+
return JSON.parse(content);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
logger.warn("Failed to load cache index, starting fresh", {
|
|
69
|
+
error: error.message,
|
|
70
|
+
});
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Save the cache index
|
|
77
|
+
*/
|
|
78
|
+
async function saveCacheIndex(index) {
|
|
79
|
+
try {
|
|
80
|
+
await ensureCacheDir();
|
|
81
|
+
await fs.writeFile(
|
|
82
|
+
CACHE_INDEX_FILE,
|
|
83
|
+
JSON.stringify(index, null, 2),
|
|
84
|
+
"utf8"
|
|
85
|
+
);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
logger.error("Failed to save cache index", { error: error.message });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Look up cached YAML for a prompt
|
|
93
|
+
*
|
|
94
|
+
* @param {string} prompt - The original test prompt
|
|
95
|
+
* @param {string} appId - App ID for context
|
|
96
|
+
* @returns {object|null} Cached entry or null if not found
|
|
97
|
+
*/
|
|
98
|
+
export async function lookupCache(prompt, appId = null) {
|
|
99
|
+
try {
|
|
100
|
+
const hash = generatePromptHash(prompt, appId);
|
|
101
|
+
const index = await loadCacheIndex();
|
|
102
|
+
|
|
103
|
+
if (!index[hash]) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const entry = index[hash];
|
|
108
|
+
const yamlPath = join(CACHE_DIR, `${hash}.yaml`);
|
|
109
|
+
|
|
110
|
+
// Verify the YAML file exists
|
|
111
|
+
if (!existsSync(yamlPath)) {
|
|
112
|
+
// Stale index entry, clean up
|
|
113
|
+
delete index[hash];
|
|
114
|
+
await saveCacheIndex(index);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Read the cached YAML
|
|
119
|
+
const yaml = await fs.readFile(yamlPath, "utf8");
|
|
120
|
+
|
|
121
|
+
logger.info(`Cache hit for prompt hash: ${hash}`);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
found: true,
|
|
125
|
+
hash,
|
|
126
|
+
yaml,
|
|
127
|
+
testName: entry.testName,
|
|
128
|
+
appId: entry.appId,
|
|
129
|
+
cachedAt: entry.cachedAt,
|
|
130
|
+
executionCount: entry.executionCount || 1,
|
|
131
|
+
lastUsed: entry.lastUsed,
|
|
132
|
+
originalPrompt: entry.prompt,
|
|
133
|
+
};
|
|
134
|
+
} catch (error) {
|
|
135
|
+
logger.error("Cache lookup error", { error: error.message });
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Save YAML to cache
|
|
142
|
+
*
|
|
143
|
+
* @param {string} prompt - The original test prompt
|
|
144
|
+
* @param {string} yaml - The generated YAML content
|
|
145
|
+
* @param {string} testName - Name of the test
|
|
146
|
+
* @param {string} appId - App ID
|
|
147
|
+
* @returns {object} Save result
|
|
148
|
+
*/
|
|
149
|
+
export async function saveToCache(prompt, yaml, testName, appId = null) {
|
|
150
|
+
try {
|
|
151
|
+
await ensureCacheDir();
|
|
152
|
+
|
|
153
|
+
const hash = generatePromptHash(prompt, appId);
|
|
154
|
+
const yamlPath = join(CACHE_DIR, `${hash}.yaml`);
|
|
155
|
+
|
|
156
|
+
// Save YAML file
|
|
157
|
+
await fs.writeFile(yamlPath, yaml, "utf8");
|
|
158
|
+
|
|
159
|
+
// Update index
|
|
160
|
+
const index = await loadCacheIndex();
|
|
161
|
+
index[hash] = {
|
|
162
|
+
prompt: prompt.substring(0, 200), // Store truncated prompt for reference
|
|
163
|
+
testName,
|
|
164
|
+
appId,
|
|
165
|
+
cachedAt: new Date().toISOString(),
|
|
166
|
+
lastUsed: new Date().toISOString(),
|
|
167
|
+
executionCount: 1,
|
|
168
|
+
};
|
|
169
|
+
await saveCacheIndex(index);
|
|
170
|
+
|
|
171
|
+
logger.info(`Cached YAML for prompt hash: ${hash}`);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
success: true,
|
|
175
|
+
hash,
|
|
176
|
+
path: yamlPath,
|
|
177
|
+
message: `YAML cached successfully. Future executions of this prompt will use the cached version.`,
|
|
178
|
+
};
|
|
179
|
+
} catch (error) {
|
|
180
|
+
logger.error("Cache save error", { error: error.message });
|
|
181
|
+
return {
|
|
182
|
+
success: false,
|
|
183
|
+
error: error.message,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Update cache usage statistics
|
|
190
|
+
*/
|
|
191
|
+
export async function updateCacheUsage(hash) {
|
|
192
|
+
try {
|
|
193
|
+
const index = await loadCacheIndex();
|
|
194
|
+
|
|
195
|
+
if (index[hash]) {
|
|
196
|
+
index[hash].lastUsed = new Date().toISOString();
|
|
197
|
+
index[hash].executionCount = (index[hash].executionCount || 0) + 1;
|
|
198
|
+
await saveCacheIndex(index);
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
logger.warn("Failed to update cache usage", { error: error.message });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Delete a cached entry
|
|
207
|
+
*/
|
|
208
|
+
export async function deleteFromCache(hash) {
|
|
209
|
+
try {
|
|
210
|
+
const index = await loadCacheIndex();
|
|
211
|
+
|
|
212
|
+
if (!index[hash]) {
|
|
213
|
+
return { success: false, error: "Cache entry not found" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Delete YAML file
|
|
217
|
+
const yamlPath = join(CACHE_DIR, `${hash}.yaml`);
|
|
218
|
+
try {
|
|
219
|
+
await fs.unlink(yamlPath);
|
|
220
|
+
} catch {
|
|
221
|
+
// File may not exist
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Remove from index
|
|
225
|
+
delete index[hash];
|
|
226
|
+
await saveCacheIndex(index);
|
|
227
|
+
|
|
228
|
+
return { success: true, message: "Cache entry deleted" };
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return { success: false, error: error.message };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Clear all cached entries
|
|
236
|
+
*/
|
|
237
|
+
export async function clearCache() {
|
|
238
|
+
try {
|
|
239
|
+
const index = await loadCacheIndex();
|
|
240
|
+
const count = Object.keys(index).length;
|
|
241
|
+
|
|
242
|
+
// Delete all YAML files
|
|
243
|
+
for (const hash of Object.keys(index)) {
|
|
244
|
+
const yamlPath = join(CACHE_DIR, `${hash}.yaml`);
|
|
245
|
+
try {
|
|
246
|
+
await fs.unlink(yamlPath);
|
|
247
|
+
} catch {
|
|
248
|
+
// Ignore
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Clear index
|
|
253
|
+
await saveCacheIndex({});
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
success: true,
|
|
257
|
+
cleared: count,
|
|
258
|
+
message: `Cleared ${count} cached test(s)`,
|
|
259
|
+
};
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return { success: false, error: error.message };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* List all cached entries
|
|
267
|
+
*/
|
|
268
|
+
export async function listCache() {
|
|
269
|
+
try {
|
|
270
|
+
const index = await loadCacheIndex();
|
|
271
|
+
|
|
272
|
+
const entries = Object.entries(index).map(([hash, entry]) => ({
|
|
273
|
+
hash,
|
|
274
|
+
testName: entry.testName,
|
|
275
|
+
appId: entry.appId,
|
|
276
|
+
prompt: entry.prompt,
|
|
277
|
+
cachedAt: entry.cachedAt,
|
|
278
|
+
lastUsed: entry.lastUsed,
|
|
279
|
+
executionCount: entry.executionCount || 1,
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
// Sort by last used (most recent first)
|
|
283
|
+
entries.sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed));
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
success: true,
|
|
287
|
+
count: entries.length,
|
|
288
|
+
cacheDir: CACHE_DIR,
|
|
289
|
+
entries,
|
|
290
|
+
};
|
|
291
|
+
} catch (error) {
|
|
292
|
+
return { success: false, error: error.message };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get cache statistics
|
|
298
|
+
*/
|
|
299
|
+
export async function getCacheStats() {
|
|
300
|
+
try {
|
|
301
|
+
const index = await loadCacheIndex();
|
|
302
|
+
const entries = Object.values(index);
|
|
303
|
+
|
|
304
|
+
const totalExecutions = entries.reduce(
|
|
305
|
+
(sum, e) => sum + (e.executionCount || 1),
|
|
306
|
+
0
|
|
307
|
+
);
|
|
308
|
+
const oldestEntry =
|
|
309
|
+
entries.length > 0
|
|
310
|
+
? entries.reduce((oldest, e) =>
|
|
311
|
+
new Date(e.cachedAt) < new Date(oldest.cachedAt) ? e : oldest
|
|
312
|
+
)
|
|
313
|
+
: null;
|
|
314
|
+
const mostUsed =
|
|
315
|
+
entries.length > 0
|
|
316
|
+
? entries.reduce((most, e) =>
|
|
317
|
+
(e.executionCount || 1) > (most.executionCount || 1) ? e : most
|
|
318
|
+
)
|
|
319
|
+
: null;
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
success: true,
|
|
323
|
+
stats: {
|
|
324
|
+
totalCached: entries.length,
|
|
325
|
+
totalExecutions,
|
|
326
|
+
cacheDir: CACHE_DIR,
|
|
327
|
+
oldestEntry: oldestEntry
|
|
328
|
+
? {
|
|
329
|
+
testName: oldestEntry.testName,
|
|
330
|
+
cachedAt: oldestEntry.cachedAt,
|
|
331
|
+
}
|
|
332
|
+
: null,
|
|
333
|
+
mostUsed: mostUsed
|
|
334
|
+
? {
|
|
335
|
+
testName: mostUsed.testName,
|
|
336
|
+
executionCount: mostUsed.executionCount,
|
|
337
|
+
}
|
|
338
|
+
: null,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
} catch (error) {
|
|
342
|
+
return { success: false, error: error.message };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Check if a prompt matches a cached entry exactly
|
|
348
|
+
* Returns comparison info for user decision
|
|
349
|
+
*/
|
|
350
|
+
export function promptMatchesCache(newPrompt, cachedPrompt) {
|
|
351
|
+
const normalizedNew = newPrompt.toLowerCase().trim().replace(/\s+/g, " ");
|
|
352
|
+
const normalizedCached = cachedPrompt
|
|
353
|
+
.toLowerCase()
|
|
354
|
+
.trim()
|
|
355
|
+
.replace(/\s+/g, " ");
|
|
356
|
+
|
|
357
|
+
if (normalizedNew === normalizedCached) {
|
|
358
|
+
return { exact: true, similarity: 100 };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Calculate simple similarity
|
|
362
|
+
const words1 = new Set(normalizedNew.split(" "));
|
|
363
|
+
const words2 = new Set(normalizedCached.split(" "));
|
|
364
|
+
const intersection = [...words1].filter((w) => words2.has(w));
|
|
365
|
+
const union = new Set([...words1, ...words2]);
|
|
366
|
+
const similarity = Math.round((intersection.length / union.size) * 100);
|
|
367
|
+
|
|
368
|
+
return { exact: false, similarity };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export default {
|
|
372
|
+
generatePromptHash,
|
|
373
|
+
lookupCache,
|
|
374
|
+
saveToCache,
|
|
375
|
+
updateCacheUsage,
|
|
376
|
+
deleteFromCache,
|
|
377
|
+
clearCache,
|
|
378
|
+
listCache,
|
|
379
|
+
getCacheStats,
|
|
380
|
+
promptMatchesCache,
|
|
381
|
+
};
|