preflight-mcp 0.1.2 → 0.1.4

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,109 @@
1
+ import http from 'node:http';
2
+ import { URL } from 'node:url';
3
+ import { logger } from '../logging/logger.js';
4
+ import { wrapPreflightError } from '../mcp/errorKinds.js';
5
+ import { generateDependencyGraph } from '../evidence/dependencyGraph.js';
6
+ import { traceQuery, traceUpsert } from '../trace/service.js';
7
+ function sendJson(res, status, body) {
8
+ const text = JSON.stringify(body, null, 2);
9
+ res.statusCode = status;
10
+ res.setHeader('content-type', 'application/json; charset=utf-8');
11
+ res.setHeader('cache-control', 'no-store');
12
+ res.end(text);
13
+ }
14
+ async function readJsonBody(req, maxBytes) {
15
+ const chunks = [];
16
+ let total = 0;
17
+ for await (const chunk of req) {
18
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
19
+ total += buf.length;
20
+ if (total > maxBytes) {
21
+ throw new Error(`Request body too large (>${maxBytes} bytes)`);
22
+ }
23
+ chunks.push(buf);
24
+ }
25
+ const raw = Buffer.concat(chunks).toString('utf8');
26
+ if (!raw.trim())
27
+ return {};
28
+ return JSON.parse(raw);
29
+ }
30
+ export function startHttpServer(cfg) {
31
+ if (!cfg.httpEnabled) {
32
+ logger.info('REST API disabled (PREFLIGHT_HTTP_ENABLED=false)');
33
+ return null;
34
+ }
35
+ const server = http.createServer(async (req, res) => {
36
+ try {
37
+ const method = (req.method ?? 'GET').toUpperCase();
38
+ const url = new URL(req.url ?? '/', `http://${cfg.httpHost}:${cfg.httpPort}`);
39
+ const pathname = url.pathname;
40
+ // Basic CORS (local development convenience). Keep permissive but local-only by default host.
41
+ res.setHeader('access-control-allow-origin', '*');
42
+ res.setHeader('access-control-allow-headers', 'content-type');
43
+ res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
44
+ if (method === 'OPTIONS') {
45
+ res.statusCode = 204;
46
+ res.end();
47
+ return;
48
+ }
49
+ if (method === 'GET' && pathname === '/health') {
50
+ sendJson(res, 200, { ok: true, name: 'preflight-mcp', time: new Date().toISOString() });
51
+ return;
52
+ }
53
+ if (method === 'POST' && pathname === '/api/v1/evidence/dependency-graph') {
54
+ const body = await readJsonBody(req, 2_000_000);
55
+ const out = await generateDependencyGraph(cfg, body);
56
+ sendJson(res, 200, out);
57
+ return;
58
+ }
59
+ if (method === 'POST' && pathname === '/api/v1/trace/upsert') {
60
+ const body = await readJsonBody(req, 2_000_000);
61
+ const out = await traceUpsert(cfg, body);
62
+ sendJson(res, 200, out);
63
+ return;
64
+ }
65
+ if (method === 'GET' && pathname === '/api/v1/trace') {
66
+ const query = Object.fromEntries(url.searchParams.entries());
67
+ // Convert known numeric params
68
+ const body = { ...query };
69
+ if (typeof query.limit === 'string')
70
+ body.limit = Number(query.limit);
71
+ if (typeof query.timeBudgetMs === 'string')
72
+ body.timeBudgetMs = Number(query.timeBudgetMs);
73
+ if (typeof query.maxBundles === 'string')
74
+ body.maxBundles = Number(query.maxBundles);
75
+ const out = await traceQuery(cfg, body);
76
+ sendJson(res, 200, out);
77
+ return;
78
+ }
79
+ sendJson(res, 404, { error: { message: `Not found: ${method} ${pathname}` } });
80
+ }
81
+ catch (err) {
82
+ const wrapped = wrapPreflightError(err);
83
+ sendJson(res, 400, { error: { message: wrapped.message } });
84
+ }
85
+ });
86
+ server.on('error', (err) => {
87
+ // Non-fatal: MCP should still work.
88
+ logger.warn(`REST server error: ${err instanceof Error ? err.message : String(err)}`);
89
+ });
90
+ try {
91
+ server.listen(cfg.httpPort, cfg.httpHost, () => {
92
+ logger.info(`REST API listening on http://${cfg.httpHost}:${cfg.httpPort}`);
93
+ });
94
+ // Allow process to exit if stdio transport closes.
95
+ server.unref();
96
+ return {
97
+ host: cfg.httpHost,
98
+ port: cfg.httpPort,
99
+ close: () => new Promise((resolve) => {
100
+ server.close(() => resolve());
101
+ }),
102
+ };
103
+ }
104
+ catch (err) {
105
+ // Binding failures should not crash MCP.
106
+ logger.warn(`Failed to start REST API server: ${err instanceof Error ? err.message : String(err)}`);
107
+ return null;
108
+ }
109
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Progress tracking for long-running bundle creation tasks.
3
+ * Enables progress notifications via MCP and prevents duplicate task creation.
4
+ */
5
+ import crypto from 'node:crypto';
6
+ function nowIso() {
7
+ return new Date().toISOString();
8
+ }
9
+ /**
10
+ * Tracks progress of bundle creation tasks.
11
+ * Provides in-memory state for active tasks and emits progress updates.
12
+ */
13
+ export class ProgressTracker {
14
+ tasks = new Map();
15
+ fingerprintToTaskId = new Map();
16
+ progressCallback;
17
+ /**
18
+ * Set a callback to receive progress updates.
19
+ * Used to forward updates to MCP progress notifications.
20
+ */
21
+ setProgressCallback(callback) {
22
+ this.progressCallback = callback;
23
+ }
24
+ /**
25
+ * Start tracking a new task.
26
+ * @returns taskId for the new task
27
+ */
28
+ startTask(fingerprint, repos) {
29
+ // Check if task already exists for this fingerprint
30
+ const existingTaskId = this.fingerprintToTaskId.get(fingerprint);
31
+ if (existingTaskId) {
32
+ const existingTask = this.tasks.get(existingTaskId);
33
+ if (existingTask && existingTask.phase !== 'complete' && existingTask.phase !== 'failed') {
34
+ // Return existing active task
35
+ return existingTaskId;
36
+ }
37
+ }
38
+ const taskId = crypto.randomUUID();
39
+ const now = nowIso();
40
+ const task = {
41
+ taskId,
42
+ fingerprint,
43
+ phase: 'starting',
44
+ progress: 0,
45
+ message: `Starting bundle creation for ${repos.join(', ')}`,
46
+ startedAt: now,
47
+ updatedAt: now,
48
+ repos,
49
+ };
50
+ this.tasks.set(taskId, task);
51
+ this.fingerprintToTaskId.set(fingerprint, taskId);
52
+ this.emitProgress(task);
53
+ return taskId;
54
+ }
55
+ /**
56
+ * Update progress for an existing task.
57
+ */
58
+ updateProgress(taskId, phase, progress, message, total) {
59
+ const task = this.tasks.get(taskId);
60
+ if (!task)
61
+ return;
62
+ task.phase = phase;
63
+ task.progress = progress;
64
+ task.message = message;
65
+ task.updatedAt = nowIso();
66
+ if (total !== undefined) {
67
+ task.total = total;
68
+ }
69
+ this.emitProgress(task);
70
+ }
71
+ /**
72
+ * Mark a task as complete.
73
+ */
74
+ completeTask(taskId, bundleId) {
75
+ const task = this.tasks.get(taskId);
76
+ if (!task)
77
+ return;
78
+ task.phase = 'complete';
79
+ task.progress = 100;
80
+ task.message = `Bundle created: ${bundleId}`;
81
+ task.updatedAt = nowIso();
82
+ task.bundleId = bundleId;
83
+ this.emitProgress(task);
84
+ // Clean up after a delay to allow final status queries
85
+ setTimeout(() => {
86
+ this.tasks.delete(taskId);
87
+ if (this.fingerprintToTaskId.get(task.fingerprint) === taskId) {
88
+ this.fingerprintToTaskId.delete(task.fingerprint);
89
+ }
90
+ }, 60_000); // Keep completed task for 1 minute
91
+ }
92
+ /**
93
+ * Mark a task as failed.
94
+ */
95
+ failTask(taskId, error) {
96
+ const task = this.tasks.get(taskId);
97
+ if (!task)
98
+ return;
99
+ task.phase = 'failed';
100
+ task.message = `Failed: ${error}`;
101
+ task.updatedAt = nowIso();
102
+ task.error = error;
103
+ this.emitProgress(task);
104
+ // Clean up after a delay
105
+ setTimeout(() => {
106
+ this.tasks.delete(taskId);
107
+ if (this.fingerprintToTaskId.get(task.fingerprint) === taskId) {
108
+ this.fingerprintToTaskId.delete(task.fingerprint);
109
+ }
110
+ }, 60_000);
111
+ }
112
+ /**
113
+ * Get task by ID.
114
+ */
115
+ getTask(taskId) {
116
+ return this.tasks.get(taskId);
117
+ }
118
+ /**
119
+ * Get task by fingerprint.
120
+ */
121
+ getTaskByFingerprint(fingerprint) {
122
+ const taskId = this.fingerprintToTaskId.get(fingerprint);
123
+ if (!taskId)
124
+ return undefined;
125
+ return this.tasks.get(taskId);
126
+ }
127
+ /**
128
+ * List all active (non-complete, non-failed) tasks.
129
+ */
130
+ listActiveTasks() {
131
+ return Array.from(this.tasks.values()).filter((t) => t.phase !== 'complete' && t.phase !== 'failed');
132
+ }
133
+ /**
134
+ * List all tasks (including recently completed/failed).
135
+ */
136
+ listAllTasks() {
137
+ return Array.from(this.tasks.values());
138
+ }
139
+ /**
140
+ * Check if a task is active (in progress).
141
+ */
142
+ isTaskActive(fingerprint) {
143
+ const task = this.getTaskByFingerprint(fingerprint);
144
+ return task !== undefined && task.phase !== 'complete' && task.phase !== 'failed';
145
+ }
146
+ /**
147
+ * Remove a task (e.g., when lock times out).
148
+ */
149
+ removeTask(taskId) {
150
+ const task = this.tasks.get(taskId);
151
+ if (task) {
152
+ this.tasks.delete(taskId);
153
+ if (this.fingerprintToTaskId.get(task.fingerprint) === taskId) {
154
+ this.fingerprintToTaskId.delete(task.fingerprint);
155
+ }
156
+ }
157
+ }
158
+ emitProgress(task) {
159
+ if (this.progressCallback) {
160
+ this.progressCallback(task);
161
+ }
162
+ }
163
+ }
164
+ // Singleton instance for global access
165
+ let globalTracker;
166
+ export function getProgressTracker() {
167
+ if (!globalTracker) {
168
+ globalTracker = new ProgressTracker();
169
+ }
170
+ return globalTracker;
171
+ }
172
+ /**
173
+ * Helper to format bytes for display.
174
+ */
175
+ export function formatBytes(bytes) {
176
+ if (bytes < 1024)
177
+ return `${bytes}B`;
178
+ if (bytes < 1024 * 1024)
179
+ return `${(bytes / 1024).toFixed(1)}KB`;
180
+ if (bytes < 1024 * 1024 * 1024)
181
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
182
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
183
+ }
184
+ /**
185
+ * Helper to calculate percentage.
186
+ */
187
+ export function calcPercent(current, total) {
188
+ if (total <= 0)
189
+ return 0;
190
+ return Math.min(100, Math.round((current / total) * 100));
191
+ }
@@ -278,6 +278,118 @@ export async function rebuildIndex(dbPathOrFiles, filesOrDbPath, opts) {
278
278
  db.close();
279
279
  }
280
280
  }
281
+ /**
282
+ * Extract code context for a search hit.
283
+ * Finds the surrounding function/class definition and surrounding lines.
284
+ */
285
+ function extractContext(fileContent, hitLineNo) {
286
+ const lines = fileContent.split('\n');
287
+ const hitIndex = hitLineNo - 1; // Convert 1-based to 0-based
288
+ if (hitIndex < 0 || hitIndex >= lines.length) {
289
+ return undefined;
290
+ }
291
+ // Extract surrounding lines (±3 lines)
292
+ const surroundStart = Math.max(0, hitIndex - 3);
293
+ const surroundEnd = Math.min(lines.length - 1, hitIndex + 3);
294
+ const surroundingLines = lines.slice(surroundStart, surroundEnd + 1);
295
+ // Find function/class definition by scanning upwards (max 50 lines)
296
+ let functionName;
297
+ let className;
298
+ let startLine = hitLineNo;
299
+ let endLine = hitLineNo;
300
+ const scanStartIndex = Math.max(0, hitIndex - 50);
301
+ // Patterns for TypeScript/JavaScript/Python/Go functions and classes
302
+ const functionPatterns = [
303
+ // TypeScript/JavaScript
304
+ /^\s*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][\w$]*)\s*\(/,
305
+ /^\s*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][\w$]*)\s*=\s*(?:async\s+)?\(/,
306
+ /^\s*(?:async\s+)?([a-zA-Z_$][\w$]*)\s*\([^)]*\)\s*\{/,
307
+ /^\s*([a-zA-Z_$][\w$]*)\s*:\s*(?:async\s+)?function\s*\(/,
308
+ // Python
309
+ /^\s*(?:async\s+)?def\s+([a-zA-Z_][\w]*)\s*\(/,
310
+ // Go
311
+ /^\s*func\s+(?:\([^)]*\)\s*)?([a-zA-Z_][\w]*)\s*\(/,
312
+ ];
313
+ const classPatterns = [
314
+ // TypeScript/JavaScript
315
+ /^\s*(?:export\s+)?(?:abstract\s+)?class\s+([a-zA-Z_$][\w$]*)/,
316
+ /^\s*(?:export\s+)?interface\s+([a-zA-Z_$][\w$]*)/,
317
+ /^\s*(?:export\s+)?type\s+([a-zA-Z_$][\w$]*)\s*=/,
318
+ // Python
319
+ /^\s*class\s+([a-zA-Z_][\w]*)\s*[:(]/,
320
+ // Go
321
+ /^\s*type\s+([a-zA-Z_][\w]*)\s+struct/,
322
+ ];
323
+ // Scan upward to find function or class definition
324
+ for (let i = hitIndex; i >= scanStartIndex; i--) {
325
+ const line = lines[i] ?? '';
326
+ // Try to match function patterns
327
+ if (!functionName) {
328
+ for (const pattern of functionPatterns) {
329
+ const match = line.match(pattern);
330
+ if (match?.[1]) {
331
+ functionName = match[1];
332
+ startLine = i + 1; // Convert to 1-based
333
+ break;
334
+ }
335
+ }
336
+ }
337
+ // Try to match class patterns (only if we haven't found function yet)
338
+ if (!className) {
339
+ for (const pattern of classPatterns) {
340
+ const match = line.match(pattern);
341
+ if (match?.[1]) {
342
+ className = match[1];
343
+ if (!functionName) {
344
+ startLine = i + 1;
345
+ }
346
+ break;
347
+ }
348
+ }
349
+ }
350
+ // If we found function name, stop scanning
351
+ if (functionName) {
352
+ break;
353
+ }
354
+ }
355
+ // Find end of the function/block by scanning downward for closing brace
356
+ // Simple bracket matching (stops at first balanced closing brace)
357
+ if (functionName || className) {
358
+ let braceCount = 0;
359
+ let foundOpenBrace = false;
360
+ for (let i = startLine - 1; i < lines.length && i < hitIndex + 100; i++) {
361
+ const line = lines[i] ?? '';
362
+ for (const char of line) {
363
+ if (char === '{') {
364
+ braceCount++;
365
+ foundOpenBrace = true;
366
+ }
367
+ else if (char === '}') {
368
+ braceCount--;
369
+ if (foundOpenBrace && braceCount === 0) {
370
+ endLine = i + 1;
371
+ return {
372
+ functionName,
373
+ className,
374
+ startLine,
375
+ endLine,
376
+ surroundingLines,
377
+ };
378
+ }
379
+ }
380
+ }
381
+ }
382
+ // If we didn't find closing brace, estimate end line
383
+ endLine = Math.min(lines.length, startLine + 50);
384
+ }
385
+ return {
386
+ functionName,
387
+ className,
388
+ startLine,
389
+ endLine,
390
+ surroundingLines,
391
+ };
392
+ }
281
393
  function tokenizeForSafeQuery(input) {
282
394
  const s = input.trim();
283
395
  if (!s)
@@ -308,7 +420,7 @@ export function buildFtsQuery(input) {
308
420
  // Quote each token to keep syntax safe.
309
421
  return tokens.map((t) => `"${t.replaceAll('"', '""')}"`).join(' OR ');
310
422
  }
311
- export function searchIndex(dbPath, query, scope, limit) {
423
+ export function searchIndex(dbPath, query, scope, limit, bundleRoot) {
312
424
  const db = new Database(dbPath, { readonly: true });
313
425
  try {
314
426
  const ftsQuery = buildFtsQuery(query);
@@ -326,13 +438,41 @@ export function searchIndex(dbPath, query, scope, limit) {
326
438
  LIMIT ?
327
439
  `);
328
440
  const rows = stmt.all(ftsQuery, limit);
329
- return rows.map((r) => ({
330
- path: r.path,
331
- repo: r.repo,
332
- kind: r.kind,
333
- lineNo: r.lineNo,
334
- snippet: r.snippet,
335
- }));
441
+ // Cache for file contents to avoid re-reading same files
442
+ const fileCache = new Map();
443
+ return rows.map((r) => {
444
+ const hit = {
445
+ path: r.path,
446
+ repo: r.repo,
447
+ kind: r.kind,
448
+ lineNo: r.lineNo,
449
+ snippet: r.snippet,
450
+ };
451
+ // Add context for code files if bundleRoot is provided
452
+ if (r.kind === 'code' && bundleRoot) {
453
+ try {
454
+ // r.path is bundleNormRelativePath (e.g., "repos/owner/repo/norm/path/to/file.ts")
455
+ // Construct absolute path to the normalized file
456
+ const filePath = path.join(bundleRoot, r.path);
457
+ // Read file content (use cache to avoid re-reading)
458
+ let fileContent = fileCache.get(filePath);
459
+ if (!fileContent) {
460
+ fileContent = fsSync.readFileSync(filePath, 'utf8');
461
+ fileCache.set(filePath, fileContent);
462
+ }
463
+ // Extract context
464
+ const context = extractContext(fileContent, r.lineNo);
465
+ if (context) {
466
+ hit.context = context;
467
+ }
468
+ }
469
+ catch (err) {
470
+ // Silently skip context extraction on error (file not found, etc.)
471
+ // Context is optional enhancement, shouldn't break search
472
+ }
473
+ }
474
+ return hit;
475
+ });
336
476
  }
337
477
  finally {
338
478
  db.close();
@@ -464,9 +604,9 @@ function generateVerificationSummary(claim, supporting, contradicting, related,
464
604
  * 2. Calculating an overall confidence score
465
605
  * 3. Providing a human-readable summary
466
606
  */
467
- export function verifyClaimInIndex(dbPath, claim, scope, limit) {
607
+ export function verifyClaimInIndex(dbPath, claim, scope, limit, bundleRoot) {
468
608
  // Get raw search results
469
- const rawHits = searchIndex(dbPath, claim, scope, limit);
609
+ const rawHits = searchIndex(dbPath, claim, scope, limit, bundleRoot);
470
610
  // Extract tokens from claim for classification
471
611
  const claimTokens = tokenizeForSafeQuery(claim);
472
612
  // Classify each hit