preflight-mcp 0.1.3 → 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.
package/dist/server.js CHANGED
@@ -3,7 +3,8 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import * as z from 'zod';
5
5
  import { getConfig } from './config.js';
6
- import { checkForUpdates, clearBundleMulti, createBundle, findBundleStorageDir, getBundlePathsForId, getEffectiveStorageDir, listBundles, repairBundle, updateBundle, } from './bundle/service.js';
6
+ import { assertBundleComplete, checkForUpdates, checkInProgressLock, clearBundleMulti, computeCreateInputFingerprint, createBundle, findBundleStorageDir, getBundlePathsForId, getEffectiveStorageDir, listBundles, repairBundle, updateBundle, } from './bundle/service.js';
7
+ import { getProgressTracker } from './jobs/progressTracker.js';
7
8
  import { readManifest } from './bundle/manifest.js';
8
9
  import { safeJoin, toBundleFileUri } from './mcp/uris.js';
9
10
  import { wrapPreflightError } from './mcp/errorKinds.js';
@@ -81,9 +82,12 @@ const RepairBundleInputSchema = {
81
82
  rebuildGuides: z.boolean().optional().describe('If true, rebuild START_HERE.md and AGENTS.md when missing/empty.'),
82
83
  rebuildOverview: z.boolean().optional().describe('If true, rebuild OVERVIEW.md when missing/empty.'),
83
84
  };
84
- const ReadFileInputSchema = {
85
- bundleId: z.string().describe('Bundle ID.'),
86
- file: z.string().default('OVERVIEW.md').describe('File path relative to bundle root. Common files: OVERVIEW.md, START_HERE.md, AGENTS.md, manifest.json, or any repo file like repos/owner/repo/norm/README.md'),
85
+ const GetTaskStatusInputSchema = {
86
+ taskId: z.string().optional().describe('Task ID to query (from BUNDLE_IN_PROGRESS error).'),
87
+ fingerprint: z.string().optional().describe('Fingerprint to query (computed from repos/libraries/topics).'),
88
+ repos: z.array(CreateRepoInputSchema).optional().describe('Repos to compute fingerprint from (alternative to fingerprint).'),
89
+ libraries: z.array(z.string()).optional().describe('Libraries for fingerprint computation.'),
90
+ topics: z.array(z.string()).optional().describe('Topics for fingerprint computation.'),
87
91
  };
88
92
  export async function startServer() {
89
93
  const cfg = getConfig();
@@ -95,7 +99,7 @@ export async function startServer() {
95
99
  startHttpServer(cfg);
96
100
  const server = new McpServer({
97
101
  name: 'preflight-mcp',
98
- version: '0.1.3',
102
+ version: '0.1.4',
99
103
  description: 'Create evidence-based preflight bundles for repositories (docs + code) with SQLite FTS search.',
100
104
  }, {
101
105
  capabilities: {
@@ -254,13 +258,20 @@ export async function startServer() {
254
258
  };
255
259
  });
256
260
  server.registerTool('preflight_read_file', {
257
- title: 'Read bundle file',
258
- description: 'Read a file from bundle. Use when: "show overview", "read file", "查看概览", "项目概览", "看README", "查看文档", "bundle详情", "bundle状态", "仓库信息". Common files: OVERVIEW.md, START_HERE.md, AGENTS.md, manifest.json (for bundle metadata/status).',
259
- inputSchema: ReadFileInputSchema,
261
+ title: 'Read bundle file(s)',
262
+ description: 'Read file(s) from bundle. Two modes: ' +
263
+ '(1) Omit "file" param → returns ALL key files (OVERVIEW.md, START_HERE.md, AGENTS.md, manifest.json, repo READMEs) in one call. ' +
264
+ '(2) Provide "file" param → returns that specific file. ' +
265
+ 'Use when: "查看bundle", "show bundle", "read overview", "bundle概览", "项目信息".',
266
+ inputSchema: {
267
+ bundleId: z.string().describe('Bundle ID to read.'),
268
+ file: z.string().optional().describe('Specific file to read. If omitted, returns all key files (OVERVIEW.md, START_HERE.md, AGENTS.md, manifest.json, repo READMEs).'),
269
+ },
260
270
  outputSchema: {
261
271
  bundleId: z.string(),
262
- file: z.string(),
263
- content: z.string(),
272
+ file: z.string().optional(),
273
+ content: z.string().optional(),
274
+ files: z.record(z.string(), z.string().nullable()).optional(),
264
275
  },
265
276
  annotations: {
266
277
  readOnlyHint: true,
@@ -271,16 +282,66 @@ export async function startServer() {
271
282
  if (!storageDir) {
272
283
  throw new Error(`Bundle not found: ${args.bundleId}`);
273
284
  }
274
- const bundleRoot = getBundlePathsForId(storageDir, args.bundleId).rootDir;
275
- const absPath = safeJoin(bundleRoot, args.file);
276
- const content = await fs.readFile(absPath, 'utf8');
277
- const out = {
278
- bundleId: args.bundleId,
279
- file: args.file,
280
- content,
281
- };
285
+ const paths = getBundlePathsForId(storageDir, args.bundleId);
286
+ const bundleRoot = paths.rootDir;
287
+ // Single file mode
288
+ if (args.file) {
289
+ const absPath = safeJoin(bundleRoot, args.file);
290
+ const content = await fs.readFile(absPath, 'utf8');
291
+ const out = { bundleId: args.bundleId, file: args.file, content };
292
+ return {
293
+ content: [{ type: 'text', text: content }],
294
+ structuredContent: out,
295
+ };
296
+ }
297
+ // Batch mode: read all key files
298
+ const keyFiles = ['OVERVIEW.md', 'START_HERE.md', 'AGENTS.md', 'manifest.json'];
299
+ const files = {};
300
+ for (const file of keyFiles) {
301
+ try {
302
+ const absPath = safeJoin(bundleRoot, file);
303
+ files[file] = await fs.readFile(absPath, 'utf8');
304
+ }
305
+ catch {
306
+ files[file] = null;
307
+ }
308
+ }
309
+ // Try to find and read repo README files
310
+ try {
311
+ const manifest = await readManifest(paths.manifestPath);
312
+ for (const repo of manifest.repos ?? []) {
313
+ if (!repo.id)
314
+ continue;
315
+ const [owner, repoName] = repo.id.split('/');
316
+ if (!owner || !repoName)
317
+ continue;
318
+ const readmeNames = ['README.md', 'readme.md', 'Readme.md', 'README.MD'];
319
+ for (const readmeName of readmeNames) {
320
+ const readmePath = `repos/${owner}/${repoName}/norm/${readmeName}`;
321
+ try {
322
+ const absPath = safeJoin(bundleRoot, readmePath);
323
+ files[readmePath] = await fs.readFile(absPath, 'utf8');
324
+ break;
325
+ }
326
+ catch {
327
+ // Try next
328
+ }
329
+ }
330
+ }
331
+ }
332
+ catch {
333
+ // Ignore manifest read errors
334
+ }
335
+ // Build combined text output
336
+ const textParts = [];
337
+ for (const [filePath, content] of Object.entries(files)) {
338
+ if (content) {
339
+ textParts.push(`=== ${filePath} ===\n${content}`);
340
+ }
341
+ }
342
+ const out = { bundleId: args.bundleId, files };
282
343
  return {
283
- content: [{ type: 'text', text: content }],
344
+ content: [{ type: 'text', text: textParts.join('\n\n') || '(no files found)' }],
284
345
  structuredContent: out,
285
346
  };
286
347
  }
@@ -376,6 +437,31 @@ export async function startServer() {
376
437
  };
377
438
  }
378
439
  catch (err) {
440
+ // Handle BUNDLE_IN_PROGRESS error specially - provide useful info instead of just error
441
+ if (err?.code === 'BUNDLE_IN_PROGRESS') {
442
+ const elapsedSec = err.startedAt
443
+ ? Math.round((Date.now() - new Date(err.startedAt).getTime()) / 1000)
444
+ : 0;
445
+ // Check current progress from tracker
446
+ const tracker = getProgressTracker();
447
+ const task = err.taskId ? tracker.getTask(err.taskId) : undefined;
448
+ const out = {
449
+ status: 'in-progress',
450
+ message: `Bundle creation already in progress. Use preflight_get_task_status to check progress.`,
451
+ taskId: err.taskId,
452
+ fingerprint: err.fingerprint,
453
+ repos: err.repos,
454
+ startedAt: err.startedAt,
455
+ elapsedSeconds: elapsedSec,
456
+ currentPhase: task?.phase,
457
+ currentProgress: task?.progress,
458
+ currentMessage: task?.message,
459
+ };
460
+ return {
461
+ content: [{ type: 'text', text: `⚠️ Bundle creation in progress (${elapsedSec}s elapsed). ${task ? `Current: ${task.phase} (${task.progress}%) - ${task.message}` : 'Use preflight_get_task_status to check progress.'}` }],
462
+ structuredContent: out,
463
+ };
464
+ }
379
465
  throw wrapPreflightError(err);
380
466
  }
381
467
  });
@@ -486,22 +572,38 @@ export async function startServer() {
486
572
  structuredContent: out,
487
573
  };
488
574
  }
489
- const { summary, changed } = await updateBundle(cfg, args.bundleId, { force: args.force });
490
- const resources = {
491
- startHere: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'START_HERE.md' }),
492
- agents: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'AGENTS.md' }),
493
- overview: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'OVERVIEW.md' }),
494
- manifest: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'manifest.json' }),
495
- };
496
- const out = {
497
- changed: args.force ? true : changed,
498
- ...summary,
499
- resources,
500
- };
501
- return {
502
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
503
- structuredContent: out,
504
- };
575
+ // Create task for progress tracking
576
+ const tracker = getProgressTracker();
577
+ const fingerprint = `update-${args.bundleId}`;
578
+ const taskId = tracker.startTask(fingerprint, [args.bundleId]);
579
+ try {
580
+ const { summary, changed } = await updateBundle(cfg, args.bundleId, {
581
+ force: args.force,
582
+ onProgress: (phase, progress, message, total) => {
583
+ tracker.updateProgress(taskId, phase, progress, message, total);
584
+ },
585
+ });
586
+ tracker.completeTask(taskId, args.bundleId);
587
+ const resources = {
588
+ startHere: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'START_HERE.md' }),
589
+ agents: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'AGENTS.md' }),
590
+ overview: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'OVERVIEW.md' }),
591
+ manifest: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'manifest.json' }),
592
+ };
593
+ const out = {
594
+ changed: args.force ? true : changed,
595
+ ...summary,
596
+ resources,
597
+ };
598
+ return {
599
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
600
+ structuredContent: out,
601
+ };
602
+ }
603
+ catch (updateErr) {
604
+ tracker.failTask(taskId, updateErr instanceof Error ? updateErr.message : String(updateErr));
605
+ throw updateErr;
606
+ }
505
607
  }
506
608
  catch (err) {
507
609
  throw wrapPreflightError(err);
@@ -607,6 +709,8 @@ export async function startServer() {
607
709
  },
608
710
  }, async (args) => {
609
711
  try {
712
+ // Check bundle completeness before any operation
713
+ await assertBundleComplete(cfg, args.bundleId);
610
714
  // Resolve bundle location across storageDirs (more robust than a single effectiveDir).
611
715
  const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
612
716
  if (!storageDir) {
@@ -643,7 +747,11 @@ export async function startServer() {
643
747
  });
644
748
  server.registerTool('preflight_evidence_dependency_graph', {
645
749
  title: 'Evidence: dependency graph (callers + imports)',
646
- description: 'Generate an evidence-based dependency graph for a target file/symbol inside a bundle. Output is deterministic (FTS + regex) and every edge includes traceable sources (file + range). This tool is read-only.',
750
+ description: 'Generate an evidence-based dependency graph. Two modes: ' +
751
+ '(1) TARGET MODE: provide target.file to analyze a specific file\'s imports and callers. ' +
752
+ '(2) GLOBAL MODE: omit target to generate a project-wide import graph of all code files. ' +
753
+ 'For target mode, file path must be bundle-relative: repos/{owner}/{repo}/norm/{path}. ' +
754
+ 'Use preflight_search_bundle to find file paths, or check OVERVIEW.md.',
647
755
  inputSchema: DependencyGraphInputSchema,
648
756
  outputSchema: {
649
757
  meta: z.any(),
@@ -655,6 +763,8 @@ export async function startServer() {
655
763
  },
656
764
  }, async (args) => {
657
765
  try {
766
+ // Check bundle completeness before generating dependency graph
767
+ await assertBundleComplete(cfg, args.bundleId);
658
768
  const out = await generateDependencyGraph(cfg, args);
659
769
  return {
660
770
  content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
@@ -679,6 +789,8 @@ export async function startServer() {
679
789
  },
680
790
  }, async (args) => {
681
791
  try {
792
+ // Check bundle completeness before trace upsert
793
+ await assertBundleComplete(cfg, args.bundleId);
682
794
  const out = await traceUpsert(cfg, args);
683
795
  return {
684
796
  content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
@@ -715,6 +827,10 @@ export async function startServer() {
715
827
  },
716
828
  }, async (args) => {
717
829
  try {
830
+ // Check bundle completeness if bundleId is provided
831
+ if (args.bundleId) {
832
+ await assertBundleComplete(cfg, args.bundleId);
833
+ }
718
834
  const out = await traceQuery(cfg, args);
719
835
  return {
720
836
  content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
@@ -763,6 +879,115 @@ export async function startServer() {
763
879
  throw wrapPreflightError(err);
764
880
  }
765
881
  });
882
+ // Get task status - for checking progress of in-progress bundle creations
883
+ server.registerTool('preflight_get_task_status', {
884
+ title: 'Get task status',
885
+ description: 'Check status of bundle creation tasks (especially in-progress ones). Use when: "check bundle creation progress", "what is the status", "查看任务状态", "下载进度". Can query by taskId (from error), fingerprint, or repos.',
886
+ inputSchema: GetTaskStatusInputSchema,
887
+ outputSchema: {
888
+ found: z.boolean(),
889
+ task: z.object({
890
+ taskId: z.string(),
891
+ fingerprint: z.string(),
892
+ phase: z.string(),
893
+ progress: z.number(),
894
+ total: z.number().optional(),
895
+ message: z.string(),
896
+ startedAt: z.string(),
897
+ updatedAt: z.string(),
898
+ repos: z.array(z.string()),
899
+ bundleId: z.string().optional(),
900
+ error: z.string().optional(),
901
+ }).optional(),
902
+ inProgressLock: z.object({
903
+ bundleId: z.string(),
904
+ status: z.string(),
905
+ startedAt: z.string().optional(),
906
+ taskId: z.string().optional(),
907
+ repos: z.array(z.string()).optional(),
908
+ elapsedSeconds: z.number().optional(),
909
+ }).optional(),
910
+ activeTasks: z.array(z.object({
911
+ taskId: z.string(),
912
+ fingerprint: z.string(),
913
+ phase: z.string(),
914
+ progress: z.number(),
915
+ message: z.string(),
916
+ repos: z.array(z.string()),
917
+ startedAt: z.string(),
918
+ })).optional(),
919
+ },
920
+ annotations: {
921
+ readOnlyHint: true,
922
+ },
923
+ }, async (args) => {
924
+ try {
925
+ const tracker = getProgressTracker();
926
+ let result = { found: false };
927
+ // Compute fingerprint if repos provided
928
+ let fingerprint = args.fingerprint;
929
+ if (!fingerprint && args.repos?.length) {
930
+ fingerprint = computeCreateInputFingerprint({
931
+ repos: args.repos,
932
+ libraries: args.libraries,
933
+ topics: args.topics,
934
+ });
935
+ }
936
+ // Query by taskId
937
+ if (args.taskId) {
938
+ const task = tracker.getTask(args.taskId);
939
+ if (task) {
940
+ result = { found: true, task };
941
+ }
942
+ }
943
+ // Query by fingerprint
944
+ else if (fingerprint) {
945
+ const task = tracker.getTaskByFingerprint(fingerprint);
946
+ if (task) {
947
+ result = { found: true, task };
948
+ }
949
+ // Also check persistent in-progress lock
950
+ const lock = await checkInProgressLock(cfg, fingerprint);
951
+ if (lock) {
952
+ const elapsedSeconds = lock.startedAt
953
+ ? Math.round((Date.now() - new Date(lock.startedAt).getTime()) / 1000)
954
+ : undefined;
955
+ result.inProgressLock = {
956
+ bundleId: lock.bundleId,
957
+ status: lock.status ?? 'unknown',
958
+ startedAt: lock.startedAt,
959
+ taskId: lock.taskId,
960
+ repos: lock.repos,
961
+ elapsedSeconds,
962
+ };
963
+ result.found = true;
964
+ }
965
+ }
966
+ // If no specific query, return all active tasks
967
+ else {
968
+ const activeTasks = tracker.listActiveTasks();
969
+ if (activeTasks.length > 0) {
970
+ result = { found: true, activeTasks };
971
+ }
972
+ }
973
+ const summary = result.found
974
+ ? result.task
975
+ ? `Task ${result.task.taskId}: ${result.task.phase} (${result.task.progress}%) - ${result.task.message}`
976
+ : result.activeTasks
977
+ ? `${result.activeTasks.length} active task(s)`
978
+ : result.inProgressLock
979
+ ? `In-progress lock found (started ${result.inProgressLock.elapsedSeconds}s ago)`
980
+ : 'Status found'
981
+ : 'No matching task found';
982
+ return {
983
+ content: [{ type: 'text', text: summary }],
984
+ structuredContent: result,
985
+ };
986
+ }
987
+ catch (err) {
988
+ throw wrapPreflightError(err);
989
+ }
990
+ });
766
991
  // Provide backward-compatible parsing of the same URI via resources/read for clients that bypass templates.
767
992
  // This is a safety net: if a client gives us a fully-specified URI, we can still serve it.
768
993
  server.registerResource('bundle-file-compat', 'preflight://bundle-file', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "preflight-mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "MCP server that creates evidence-based preflight bundles for GitHub repositories and library docs.",
5
5
  "type": "module",
6
6
  "license": "MIT",