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.
- package/README.md +47 -67
- package/README.zh-CN.md +141 -124
- package/dist/ast/treeSitter.js +588 -0
- package/dist/bundle/analysis.js +47 -0
- package/dist/bundle/cleanup.js +155 -0
- package/dist/bundle/context7.js +65 -36
- package/dist/bundle/facts.js +829 -0
- package/dist/bundle/githubArchive.js +49 -28
- package/dist/bundle/overview.js +226 -48
- package/dist/bundle/service.js +124 -156
- package/dist/config.js +29 -3
- package/dist/context7/client.js +5 -2
- package/dist/evidence/dependencyGraph.js +826 -0
- package/dist/http/server.js +109 -0
- package/dist/search/sqliteFts.js +150 -10
- package/dist/server.js +125 -293
- package/dist/trace/service.js +108 -0
- package/dist/trace/store.js +170 -0
- package/package.json +4 -2
- package/dist/bundle/deepwiki.js +0 -206
|
@@ -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
|
+
}
|
package/dist/bundle/context7.js
CHANGED
|
@@ -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
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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);
|