preflight-mcp 0.1.1 → 0.1.3

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,155 @@
1
+ /**
2
+ * Bundle cleanup utilities for MCP architecture
3
+ * Designed to run on-demand (startup, list, etc.) rather than as a daemon
4
+ */
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { logger } from '../logging/logger.js';
8
+ import { rmIfExists } from '../utils/index.js';
9
+ /**
10
+ * Check if a string is a valid UUID (v4 format)
11
+ */
12
+ function isValidBundleId(id) {
13
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
+ return uuidRegex.test(id);
15
+ }
16
+ /**
17
+ * Check if a bundle is orphaned (incomplete/corrupted)
18
+ */
19
+ async function isOrphanBundle(storageDir, bundleId) {
20
+ const bundlePath = path.join(storageDir, bundleId);
21
+ const manifestPath = path.join(bundlePath, 'manifest.json');
22
+ try {
23
+ // Check if manifest exists and is valid
24
+ const manifestContent = await fs.readFile(manifestPath, 'utf8');
25
+ const manifest = JSON.parse(manifestContent);
26
+ // Valid manifest exists
27
+ if (manifest.bundleId && manifest.schemaVersion) {
28
+ return { isOrphan: false };
29
+ }
30
+ return { isOrphan: true, reason: 'invalid manifest' };
31
+ }
32
+ catch {
33
+ // Manifest missing or unreadable
34
+ try {
35
+ const stats = await fs.stat(bundlePath);
36
+ const ageHours = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60);
37
+ return { isOrphan: true, reason: 'missing manifest', ageHours };
38
+ }
39
+ catch {
40
+ return { isOrphan: true, reason: 'directory inaccessible' };
41
+ }
42
+ }
43
+ }
44
+ /**
45
+ * Clean up orphan bundles from a single storage directory
46
+ * Only removes bundles older than minAgeHours to avoid race conditions
47
+ */
48
+ async function cleanupOrphansInDir(storageDir, options) {
49
+ const found = [];
50
+ const cleaned = [];
51
+ const skipped = [];
52
+ try {
53
+ const entries = await fs.readdir(storageDir, { withFileTypes: true });
54
+ for (const entry of entries) {
55
+ if (!entry.isDirectory())
56
+ continue;
57
+ // Handle .deleting directories (from background deletion)
58
+ if (entry.name.endsWith('.deleting')) {
59
+ // Always clean .deleting directories (they're already marked for deletion)
60
+ try {
61
+ const deletingPath = path.join(storageDir, entry.name);
62
+ await rmIfExists(deletingPath);
63
+ logger.info(`Cleaned pending deletion: ${entry.name}`);
64
+ }
65
+ catch (err) {
66
+ logger.warn(`Failed to clean pending deletion ${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
67
+ }
68
+ continue;
69
+ }
70
+ // Only process directories with valid UUID names
71
+ if (!isValidBundleId(entry.name)) {
72
+ continue;
73
+ }
74
+ const bundleId = entry.name;
75
+ const orphanCheck = await isOrphanBundle(storageDir, bundleId);
76
+ if (orphanCheck.isOrphan) {
77
+ found.push(bundleId);
78
+ // Check age threshold
79
+ if (orphanCheck.ageHours !== undefined && orphanCheck.ageHours < options.minAgeHours) {
80
+ skipped.push({
81
+ bundleId,
82
+ reason: `too new (${orphanCheck.ageHours.toFixed(1)}h < ${options.minAgeHours}h)`,
83
+ });
84
+ continue;
85
+ }
86
+ if (!options.dryRun) {
87
+ try {
88
+ const bundlePath = path.join(storageDir, bundleId);
89
+ await rmIfExists(bundlePath);
90
+ cleaned.push(bundleId);
91
+ logger.info(`Cleaned orphan bundle: ${bundleId} (${orphanCheck.reason})`);
92
+ }
93
+ catch (err) {
94
+ skipped.push({
95
+ bundleId,
96
+ reason: `cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
97
+ });
98
+ }
99
+ }
100
+ else {
101
+ cleaned.push(bundleId); // In dry-run, mark as "would clean"
102
+ }
103
+ }
104
+ }
105
+ }
106
+ catch (err) {
107
+ logger.warn(`Failed to scan storage dir ${storageDir}: ${err instanceof Error ? err.message : String(err)}`);
108
+ }
109
+ return { found, cleaned, skipped };
110
+ }
111
+ /**
112
+ * Clean up orphan bundles across all storage directories
113
+ * Safe to call on every server startup - fast when no orphans exist
114
+ */
115
+ export async function cleanupOrphanBundles(cfg, options) {
116
+ const minAgeHours = options?.minAgeHours ?? 1; // Default: 1 hour safety margin
117
+ const dryRun = options?.dryRun ?? false;
118
+ const details = [];
119
+ let totalFound = 0;
120
+ let totalCleaned = 0;
121
+ for (const storageDir of cfg.storageDirs) {
122
+ const result = await cleanupOrphansInDir(storageDir, { minAgeHours, dryRun });
123
+ totalFound += result.found.length;
124
+ totalCleaned += result.cleaned.length;
125
+ if (result.found.length > 0) {
126
+ details.push({
127
+ storageDir,
128
+ ...result,
129
+ });
130
+ }
131
+ }
132
+ if (totalFound > 0) {
133
+ logger.info(`Orphan cleanup: found ${totalFound}, cleaned ${totalCleaned}, skipped ${totalFound - totalCleaned}${dryRun ? ' (dry-run)' : ''}`);
134
+ }
135
+ return { totalFound, totalCleaned, details };
136
+ }
137
+ /**
138
+ * Run orphan cleanup on server startup (best-effort, non-blocking)
139
+ * Only logs warnings on failure, doesn't throw
140
+ */
141
+ export async function cleanupOnStartup(cfg) {
142
+ try {
143
+ const result = await cleanupOrphanBundles(cfg, {
144
+ minAgeHours: 1,
145
+ dryRun: false,
146
+ });
147
+ if (result.totalCleaned > 0) {
148
+ logger.info(`Startup cleanup: removed ${result.totalCleaned} orphan bundle(s)`);
149
+ }
150
+ }
151
+ catch (err) {
152
+ // Non-critical: just log and continue
153
+ logger.warn(`Startup cleanup failed (non-critical): ${err instanceof Error ? err.message : String(err)}`);
154
+ }
155
+ }
@@ -138,15 +138,34 @@ async function writeJson(targetPath, obj) {
138
138
  await ensureDir(path.dirname(targetPath));
139
139
  await fs.writeFile(targetPath, JSON.stringify(obj, null, 2) + '\n', 'utf8');
140
140
  }
141
+ async function sleep(ms) {
142
+ return new Promise((resolve) => setTimeout(resolve, ms));
143
+ }
141
144
  async function callOrThrow(client, name, args) {
142
- const res = await client.callTool({ name, arguments: args });
143
- if (res.isError) {
144
- throw new Error(textFromToolResult(res) || `${name} failed`);
145
+ const maxRetries = 3;
146
+ let lastError;
147
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
148
+ try {
149
+ const res = await client.callTool({ name, arguments: args });
150
+ if (res.isError) {
151
+ throw new Error(textFromToolResult(res) || `${name} failed`);
152
+ }
153
+ return {
154
+ text: textFromToolResult(res),
155
+ structured: res.structuredContent,
156
+ };
157
+ }
158
+ catch (err) {
159
+ lastError = err instanceof Error ? err : new Error(String(err));
160
+ // Don't retry on last attempt
161
+ if (attempt < maxRetries - 1) {
162
+ // Exponential backoff: 1s, 2s, 4s
163
+ const delayMs = Math.pow(2, attempt) * 1000;
164
+ await sleep(delayMs);
165
+ }
166
+ }
145
167
  }
146
- return {
147
- text: textFromToolResult(res),
148
- structured: res.structuredContent,
149
- };
168
+ throw lastError || new Error(`${name} failed after ${maxRetries} retries`);
150
169
  }
151
170
  async function resolveContext7Id(client, input) {
152
171
  const notes = [];
@@ -154,38 +173,48 @@ async function resolveContext7Id(client, input) {
154
173
  if (trimmed.startsWith('/')) {
155
174
  return { id: trimmed, notes };
156
175
  }
157
- try {
158
- const res = await client.callTool({
159
- name: 'resolve-library-id',
160
- arguments: { libraryName: trimmed },
161
- });
162
- if (res.isError) {
163
- notes.push(`resolve-library-id error: ${textFromToolResult(res)}`);
164
- return { notes };
165
- }
166
- const text = textFromToolResult(res);
167
- // Prefer parsing the structured list output for better selection.
168
- const parsed = parseResolveEntries(text);
169
- const chosen = chooseBestEntry(parsed, trimmed);
170
- if (chosen.id) {
171
- notes.push(...chosen.notes);
172
- return { id: chosen.id, notes };
173
- }
174
- // Fallback: regex/structured extraction.
175
- const ids = extractContext7IdsFromResult(res);
176
- if (ids.length === 0) {
177
- notes.push('resolve-library-id returned no Context7 IDs');
178
- return { notes };
176
+ const maxRetries = 3;
177
+ let lastError;
178
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
179
+ try {
180
+ const res = await client.callTool({
181
+ name: 'resolve-library-id',
182
+ arguments: { libraryName: trimmed },
183
+ });
184
+ if (res.isError) {
185
+ notes.push(`resolve-library-id error: ${textFromToolResult(res)}`);
186
+ return { notes };
187
+ }
188
+ const text = textFromToolResult(res);
189
+ // Prefer parsing the structured list output for better selection.
190
+ const parsed = parseResolveEntries(text);
191
+ const chosen = chooseBestEntry(parsed, trimmed);
192
+ if (chosen.id) {
193
+ notes.push(...chosen.notes);
194
+ return { id: chosen.id, notes };
195
+ }
196
+ // Fallback: regex/structured extraction.
197
+ const ids = extractContext7IdsFromResult(res);
198
+ if (ids.length === 0) {
199
+ notes.push('resolve-library-id returned no Context7 IDs');
200
+ return { notes };
201
+ }
202
+ if (ids.length > 1) {
203
+ notes.push(`resolve-library-id returned multiple IDs; using first: ${ids[0]}`);
204
+ }
205
+ return { id: ids[0], notes };
179
206
  }
180
- if (ids.length > 1) {
181
- notes.push(`resolve-library-id returned multiple IDs; using first: ${ids[0]}`);
207
+ catch (err) {
208
+ lastError = err instanceof Error ? err : new Error(String(err));
209
+ // Don't retry on last attempt
210
+ if (attempt < maxRetries - 1) {
211
+ const delayMs = Math.pow(2, attempt) * 1000;
212
+ await sleep(delayMs);
213
+ }
182
214
  }
183
- return { id: ids[0], notes };
184
- }
185
- catch (err) {
186
- notes.push(`resolve-library-id threw: ${err instanceof Error ? err.message : String(err)}`);
187
- return { notes };
188
215
  }
216
+ notes.push(`resolve-library-id failed after ${maxRetries} retries: ${lastError?.message ?? 'unknown error'}`);
217
+ return { notes };
189
218
  }
190
219
  export async function ingestContext7Libraries(params) {
191
220
  const libs = (params.libraries ?? []).map((s) => s.trim()).filter(Boolean);