preflight-mcp 0.1.0 → 0.1.2

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,16 +3,27 @@ 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 { bundleExists, checkForUpdates, clearBundleMulti, createBundle, getBundlePathsForId, getEffectiveStorageDir, listBundles, updateBundle, } from './bundle/service.js';
6
+ import { bundleExists, checkForUpdates, clearBundleMulti, createBundle, findBundleByInputs, computeCreateInputFingerprint, findBundleStorageDir, getBundlePathsForId, getEffectiveStorageDir, listBundles, repairBundle, updateBundle, } from './bundle/service.js';
7
7
  import { readManifest } from './bundle/manifest.js';
8
8
  import { safeJoin, toBundleFileUri } from './mcp/uris.js';
9
+ import { wrapPreflightError } from './mcp/errorKinds.js';
9
10
  import { searchIndex, verifyClaimInIndex } from './search/sqliteFts.js';
11
+ import { runSearchByTags } from './tools/searchByTags.js';
12
+ import { cleanupOnStartup, cleanupOrphanBundles } from './bundle/cleanup.js';
10
13
  const CreateRepoInputSchema = z.union([
11
14
  z.object({
12
15
  kind: z.literal('github'),
13
16
  repo: z.string().describe('GitHub repo in owner/repo form (or github.com/owner/repo URL).'),
14
17
  ref: z.string().optional().describe('Optional git ref (branch/tag).'),
15
18
  }),
19
+ z.object({
20
+ kind: z.literal('local'),
21
+ repo: z
22
+ .string()
23
+ .describe('Logical repo id in owner/repo form (used for storage layout and de-dup).'),
24
+ path: z.string().describe('Local directory path containing the repository files.'),
25
+ ref: z.string().optional().describe('Optional label/ref for the local snapshot.'),
26
+ }),
16
27
  z.object({
17
28
  kind: z.literal('deepwiki'),
18
29
  url: z.string().url().describe('DeepWiki URL (https://deepwiki.com/owner/repo).'),
@@ -22,6 +33,10 @@ const CreateBundleInputSchema = {
22
33
  repos: z.array(CreateRepoInputSchema).min(1).describe('Repositories to ingest into the bundle.'),
23
34
  libraries: z.array(z.string()).optional().describe('Optional library names for Context7 docs ingestion.'),
24
35
  topics: z.array(z.string()).optional().describe('Optional Context7 topic filters (limits fetched docs).'),
36
+ ifExists: z
37
+ .enum(['error', 'returnExisting', 'updateExisting', 'createNew'])
38
+ .default('error')
39
+ .describe('What to do if a bundle with the same normalized inputs already exists. error=reject (default), returnExisting=return existing without fetching, updateExisting=update existing bundle then return it, createNew=bypass de-duplication.'),
25
40
  };
26
41
  const UpdateBundleInputSchema = {
27
42
  bundleId: z.string().describe('Bundle ID returned by preflight_create_bundle.'),
@@ -39,8 +54,19 @@ const SearchBundleInputSchema = {
39
54
  query: z.string().describe('Search query. Prefix with fts: to use raw FTS syntax.'),
40
55
  scope: z.enum(['docs', 'code', 'all']).default('all').describe('Search scope.'),
41
56
  limit: z.number().int().min(1).max(200).default(30).describe('Max number of hits.'),
42
- ensureFresh: z.boolean().optional().describe('If true, check if bundle needs update before searching.'),
43
- maxAgeHours: z.number().optional().describe('Max age in hours before triggering auto-update (requires ensureFresh).'),
57
+ // Deprecated (kept for backward compatibility): this tool is strictly read-only.
58
+ ensureFresh: z
59
+ .boolean()
60
+ .optional()
61
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-update. Use preflight_update_bundle, then call search again.'),
62
+ maxAgeHours: z
63
+ .number()
64
+ .optional()
65
+ .describe('DEPRECATED. Only used with ensureFresh (which is deprecated).'),
66
+ autoRepairIndex: z
67
+ .boolean()
68
+ .optional()
69
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair. Use preflight_repair_bundle, then call search again.'),
44
70
  };
45
71
  const SearchByTagsInputSchema = {
46
72
  query: z.string().describe('Search query across bundles.'),
@@ -53,8 +79,19 @@ const VerifyClaimInputSchema = {
53
79
  claim: z.string().describe('A claim to look for evidence for (best-effort).'),
54
80
  scope: z.enum(['docs', 'code', 'all']).default('all').describe('Search scope.'),
55
81
  limit: z.number().int().min(1).max(50).default(8).describe('Max number of evidence hits.'),
56
- ensureFresh: z.boolean().optional().describe('If true, check if bundle needs update before verifying.'),
57
- maxAgeHours: z.number().optional().describe('Max age in hours before triggering auto-update (requires ensureFresh).'),
82
+ // Deprecated (kept for backward compatibility): this tool is strictly read-only.
83
+ ensureFresh: z
84
+ .boolean()
85
+ .optional()
86
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-update. Use preflight_update_bundle, then call verify again.'),
87
+ maxAgeHours: z
88
+ .number()
89
+ .optional()
90
+ .describe('DEPRECATED. Only used with ensureFresh (which is deprecated).'),
91
+ autoRepairIndex: z
92
+ .boolean()
93
+ .optional()
94
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair. Use preflight_repair_bundle, then call verify again.'),
58
95
  };
59
96
  const ListBundlesInputSchema = {
60
97
  // keep open for future filters
@@ -62,6 +99,13 @@ const ListBundlesInputSchema = {
62
99
  const DeleteBundleInputSchema = {
63
100
  bundleId: z.string().describe('Bundle ID to delete.'),
64
101
  };
102
+ const RepairBundleInputSchema = {
103
+ bundleId: z.string().describe('Bundle ID to repair.'),
104
+ mode: z.enum(['validate', 'repair']).default('repair').describe('validate=report missing components only; repair=fix missing derived artifacts.'),
105
+ rebuildIndex: z.boolean().optional().describe('If true, rebuild search index when missing/empty.'),
106
+ rebuildGuides: z.boolean().optional().describe('If true, rebuild START_HERE.md and AGENTS.md when missing/empty.'),
107
+ rebuildOverview: z.boolean().optional().describe('If true, rebuild OVERVIEW.md when missing/empty.'),
108
+ };
65
109
  const BundleInfoInputSchema = {
66
110
  bundleId: z.string().describe('Bundle ID to get info for.'),
67
111
  };
@@ -71,9 +115,13 @@ const ReadFileInputSchema = {
71
115
  };
72
116
  export async function startServer() {
73
117
  const cfg = getConfig();
118
+ // Run orphan bundle cleanup on startup (non-blocking, best-effort)
119
+ cleanupOnStartup(cfg).catch(() => {
120
+ // Errors already logged, don't block server startup
121
+ });
74
122
  const server = new McpServer({
75
123
  name: 'preflight-mcp',
76
- version: '0.1.0',
124
+ version: '0.1.2',
77
125
  description: 'Create evidence-based preflight bundles for repositories (docs + code) with SQLite FTS search.',
78
126
  }, {
79
127
  capabilities: {
@@ -144,74 +192,90 @@ export async function startServer() {
144
192
  });
145
193
  server.registerTool('preflight_list_bundles', {
146
194
  title: 'List bundles',
147
- description: 'List all preflight bundles with metadata. Use when: "show bundles", "what bundles exist", "list repos", "show my knowledge bases", "what have I indexed", "查看bundle", "有哪些bundle", "列出仓库".',
195
+ description: 'List available preflight bundles in a stable, minimal format. Use when: "show bundles", "what bundles exist", "list repos", "show my knowledge bases", "what have I indexed", "查看bundle", "有哪些bundle", "列出仓库".',
148
196
  inputSchema: {
149
- filterByTag: z.string().optional().describe('Filter by tag (e.g., "mcp", "agents", "web-scraping")'),
150
- groupByCategory: z.boolean().default(false).describe('Group bundles by auto-detected categories'),
197
+ filterByTag: z
198
+ .string()
199
+ .optional()
200
+ .describe('Filter by tag (e.g., "mcp", "agents", "web-scraping").'),
201
+ limit: z
202
+ .number()
203
+ .int()
204
+ .min(1)
205
+ .max(200)
206
+ .default(50)
207
+ .describe('Max number of bundles to return.'),
208
+ maxItemsPerList: z
209
+ .number()
210
+ .int()
211
+ .min(1)
212
+ .max(50)
213
+ .default(10)
214
+ .describe('Max repos/tags to include per bundle to keep output compact.'),
151
215
  },
152
216
  outputSchema: {
153
217
  bundles: z.array(z.object({
154
218
  bundleId: z.string(),
155
- displayName: z.string().optional(),
156
- description: z.string().optional(),
157
- tags: z.array(z.string()).optional(),
158
- primaryLanguage: z.string().optional(),
159
- category: z.string().optional(),
160
- repoCount: z.number(),
219
+ displayName: z.string(),
220
+ repos: z.array(z.string()),
221
+ tags: z.array(z.string()),
161
222
  })),
162
- grouped: z.record(z.string(), z.array(z.string())).optional(),
163
223
  },
164
224
  annotations: {
165
225
  readOnlyHint: true,
166
226
  },
167
227
  }, async (args) => {
168
228
  const effectiveDir = await getEffectiveStorageDir(cfg);
169
- const ids = await listBundles(effectiveDir);
170
- // Read manifests for each bundle
171
- const bundlesWithMeta = [];
229
+ const ids = (await listBundles(effectiveDir)).slice(0, args.limit);
230
+ const capList = (items, max) => {
231
+ if (items.length <= max)
232
+ return items;
233
+ const keep = items.slice(0, max);
234
+ keep.push(`...(+${items.length - max})`);
235
+ return keep;
236
+ };
237
+ const bundlesInternal = [];
172
238
  for (const id of ids) {
173
239
  try {
174
240
  const paths = getBundlePathsForId(effectiveDir, id);
175
241
  const manifest = await readManifest(paths.manifestPath);
176
- // Import getCategoryFromTags dynamically
177
- const { getCategoryFromTags } = await import('./bundle/tagging.js');
178
- const category = manifest.tags ? getCategoryFromTags(manifest.tags) : 'uncategorized';
179
- bundlesWithMeta.push({
242
+ const reposRaw = (manifest.repos ?? []).map((r) => r.id).filter(Boolean);
243
+ const tagsFull = (manifest.tags ?? []).map(String);
244
+ const displayName = (manifest.displayName && manifest.displayName.trim()) ||
245
+ (reposRaw[0] && reposRaw[0].trim()) ||
246
+ '(unnamed)';
247
+ bundlesInternal.push({
180
248
  bundleId: id,
181
- displayName: manifest.displayName,
182
- description: manifest.description,
183
- tags: manifest.tags,
184
- primaryLanguage: manifest.primaryLanguage,
185
- category,
186
- repoCount: manifest.repos.length,
249
+ displayName,
250
+ repos: capList(reposRaw, args.maxItemsPerList),
251
+ tags: capList(tagsFull, args.maxItemsPerList),
252
+ tagsFull,
187
253
  });
188
254
  }
189
255
  catch {
190
- // Skip bundles with missing/corrupt manifests
191
- }
192
- }
193
- // Filter by tag if specified
194
- let filtered = bundlesWithMeta;
195
- if (args.filterByTag) {
196
- filtered = bundlesWithMeta.filter((b) => b.tags && b.tags.includes(args.filterByTag));
197
- }
198
- // Group by category if requested
199
- let grouped;
200
- if (args.groupByCategory) {
201
- grouped = {};
202
- for (const bundle of filtered) {
203
- const cat = bundle.category || 'uncategorized';
204
- if (!grouped[cat])
205
- grouped[cat] = [];
206
- grouped[cat].push(bundle.bundleId);
256
+ // Keep the bundleId visible even if the manifest is missing/corrupt.
257
+ bundlesInternal.push({
258
+ bundleId: id,
259
+ displayName: '(unreadable manifest)',
260
+ repos: [],
261
+ tags: [],
262
+ tagsFull: [],
263
+ });
207
264
  }
208
265
  }
209
- const out = {
210
- bundles: filtered,
211
- grouped,
212
- };
266
+ const filteredInternal = args.filterByTag
267
+ ? bundlesInternal.filter((b) => b.tagsFull.includes(args.filterByTag))
268
+ : bundlesInternal;
269
+ const filtered = filteredInternal.map(({ tagsFull: _tagsFull, ...b }) => b);
270
+ const out = { bundles: filtered };
271
+ // Stable human-readable format for UI logs.
272
+ const lines = filtered.map((b) => {
273
+ const repos = b.repos.length ? b.repos.join(', ') : '(none)';
274
+ const tags = b.tags.length ? b.tags.join(', ') : '(none)';
275
+ return `${b.bundleId} | ${b.displayName} | repos: ${repos} | tags: ${tags}`;
276
+ });
213
277
  return {
214
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
278
+ content: [{ type: 'text', text: lines.join('\n') || '(no bundles)' }],
215
279
  structuredContent: out,
216
280
  };
217
281
  });
@@ -228,23 +292,27 @@ export async function startServer() {
228
292
  readOnlyHint: true,
229
293
  },
230
294
  }, async (args) => {
231
- const effectiveDir = await getEffectiveStorageDir(cfg);
232
- const exists = await bundleExists(effectiveDir, args.bundleId);
233
- if (!exists) {
234
- throw new Error(`Bundle not found: ${args.bundleId}`);
295
+ try {
296
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
297
+ if (!storageDir) {
298
+ throw new Error(`Bundle not found: ${args.bundleId}`);
299
+ }
300
+ const bundleRoot = getBundlePathsForId(storageDir, args.bundleId).rootDir;
301
+ const absPath = safeJoin(bundleRoot, args.file);
302
+ const content = await fs.readFile(absPath, 'utf8');
303
+ const out = {
304
+ bundleId: args.bundleId,
305
+ file: args.file,
306
+ content,
307
+ };
308
+ return {
309
+ content: [{ type: 'text', text: content }],
310
+ structuredContent: out,
311
+ };
312
+ }
313
+ catch (err) {
314
+ throw wrapPreflightError(err);
235
315
  }
236
- const bundleRoot = getBundlePathsForId(effectiveDir, args.bundleId).rootDir;
237
- const absPath = safeJoin(bundleRoot, args.file);
238
- const content = await fs.readFile(absPath, 'utf8');
239
- const out = {
240
- bundleId: args.bundleId,
241
- file: args.file,
242
- content,
243
- };
244
- return {
245
- content: [{ type: 'text', text: content }],
246
- structuredContent: out,
247
- };
248
316
  });
249
317
  server.registerTool('preflight_delete_bundle', {
250
318
  title: 'Delete bundle',
@@ -258,16 +326,21 @@ export async function startServer() {
258
326
  destructiveHint: true,
259
327
  },
260
328
  }, async (args) => {
261
- const deleted = await clearBundleMulti(cfg.storageDirs, args.bundleId);
262
- if (!deleted) {
263
- throw new Error(`Bundle not found: ${args.bundleId}`);
329
+ try {
330
+ const deleted = await clearBundleMulti(cfg.storageDirs, args.bundleId);
331
+ if (!deleted) {
332
+ throw new Error(`Bundle not found: ${args.bundleId}`);
333
+ }
334
+ server.sendResourceListChanged();
335
+ const out = { deleted: true, bundleId: args.bundleId };
336
+ return {
337
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
338
+ structuredContent: out,
339
+ };
340
+ }
341
+ catch (err) {
342
+ throw wrapPreflightError(err);
264
343
  }
265
- server.sendResourceListChanged();
266
- const out = { deleted: true, bundleId: args.bundleId };
267
- return {
268
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
269
- structuredContent: out,
270
- };
271
344
  });
272
345
  server.registerTool('preflight_bundle_info', {
273
346
  title: 'Bundle info',
@@ -278,8 +351,9 @@ export async function startServer() {
278
351
  createdAt: z.string(),
279
352
  updatedAt: z.string(),
280
353
  repos: z.array(z.object({
281
- kind: z.enum(['github', 'deepwiki']),
354
+ kind: z.enum(['github', 'local', 'deepwiki']),
282
355
  id: z.string(),
356
+ source: z.enum(['git', 'archive', 'local', 'deepwiki']).optional(),
283
357
  headSha: z.string().optional(),
284
358
  fetchedAt: z.string().optional(),
285
359
  notes: z.array(z.string()).optional(),
@@ -310,36 +384,89 @@ export async function startServer() {
310
384
  readOnlyHint: true,
311
385
  },
312
386
  }, async (args) => {
313
- const effectiveDir = await getEffectiveStorageDir(cfg);
314
- const exists = await bundleExists(effectiveDir, args.bundleId);
315
- if (!exists) {
316
- throw new Error(`Bundle not found: ${args.bundleId}`);
387
+ try {
388
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
389
+ if (!storageDir) {
390
+ throw new Error(`Bundle not found: ${args.bundleId}`);
391
+ }
392
+ const paths = getBundlePathsForId(storageDir, args.bundleId);
393
+ const manifest = await readManifest(paths.manifestPath);
394
+ const resources = {
395
+ startHere: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'START_HERE.md' }),
396
+ agents: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'AGENTS.md' }),
397
+ overview: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'OVERVIEW.md' }),
398
+ manifest: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'manifest.json' }),
399
+ };
400
+ const out = {
401
+ bundleId: manifest.bundleId,
402
+ createdAt: manifest.createdAt,
403
+ updatedAt: manifest.updatedAt,
404
+ repos: manifest.repos,
405
+ libraries: manifest.libraries,
406
+ index: manifest.index,
407
+ resources,
408
+ };
409
+ return {
410
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
411
+ structuredContent: out,
412
+ };
413
+ }
414
+ catch (err) {
415
+ throw wrapPreflightError(err);
416
+ }
417
+ });
418
+ server.registerTool('preflight_find_bundle', {
419
+ title: 'Find existing bundle',
420
+ description: 'Check whether a bundle already exists for the given inputs (no fetching, no changes). Use when: "does this repo already exist", "have I indexed this", "find bundle for", "这个项目是否已索引".',
421
+ inputSchema: {
422
+ repos: z.array(CreateRepoInputSchema).min(1),
423
+ libraries: z.array(z.string()).optional(),
424
+ topics: z.array(z.string()).optional(),
425
+ },
426
+ outputSchema: {
427
+ found: z.boolean(),
428
+ bundleId: z.string().optional(),
429
+ fingerprint: z.string(),
430
+ },
431
+ annotations: {
432
+ readOnlyHint: true,
433
+ },
434
+ }, async (args) => {
435
+ try {
436
+ const fingerprint = computeCreateInputFingerprint({
437
+ repos: args.repos,
438
+ libraries: args.libraries,
439
+ topics: args.topics,
440
+ });
441
+ const bundleId = await findBundleByInputs(cfg, {
442
+ repos: args.repos,
443
+ libraries: args.libraries,
444
+ topics: args.topics,
445
+ });
446
+ const out = {
447
+ found: !!bundleId,
448
+ bundleId: bundleId ?? undefined,
449
+ fingerprint,
450
+ };
451
+ return {
452
+ content: [
453
+ {
454
+ type: 'text',
455
+ text: out.found
456
+ ? `FOUND ${out.bundleId} (fingerprint=${out.fingerprint})`
457
+ : `NOT_FOUND (fingerprint=${out.fingerprint})`,
458
+ },
459
+ ],
460
+ structuredContent: out,
461
+ };
462
+ }
463
+ catch (err) {
464
+ throw wrapPreflightError(err);
317
465
  }
318
- const paths = getBundlePathsForId(effectiveDir, args.bundleId);
319
- const manifest = await readManifest(paths.manifestPath);
320
- const resources = {
321
- startHere: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'START_HERE.md' }),
322
- agents: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'AGENTS.md' }),
323
- overview: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'OVERVIEW.md' }),
324
- manifest: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'manifest.json' }),
325
- };
326
- const out = {
327
- bundleId: manifest.bundleId,
328
- createdAt: manifest.createdAt,
329
- updatedAt: manifest.updatedAt,
330
- repos: manifest.repos,
331
- libraries: manifest.libraries,
332
- index: manifest.index,
333
- resources,
334
- };
335
- return {
336
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
337
- structuredContent: out,
338
- };
339
466
  });
340
467
  server.registerTool('preflight_create_bundle', {
341
468
  title: 'Create bundle',
342
- description: 'Create a new bundle from GitHub repos or DeepWiki. Use when: "index this repo", "create bundle for", "add repo to preflight", "索引这个仓库", "创建bundle", "添加GitHub项目", "学习这个项目".',
469
+ description: 'Create a new bundle from GitHub repos or DeepWiki (or update an existing one if ifExists=updateExisting). Use when: "index this repo", "create bundle for", "add repo to preflight", "索引这个仓库", "创建bundle", "添加GitHub项目", "学习这个项目".',
343
470
  inputSchema: CreateBundleInputSchema,
344
471
  outputSchema: {
345
472
  bundleId: z.string(),
@@ -352,8 +479,9 @@ export async function startServer() {
352
479
  manifest: z.string(),
353
480
  }),
354
481
  repos: z.array(z.object({
355
- kind: z.enum(['github', 'deepwiki']),
482
+ kind: z.enum(['github', 'local', 'deepwiki']),
356
483
  id: z.string(),
484
+ source: z.enum(['git', 'archive', 'local', 'deepwiki']).optional(),
357
485
  headSha: z.string().optional(),
358
486
  notes: z.array(z.string()).optional(),
359
487
  })),
@@ -372,27 +500,76 @@ export async function startServer() {
372
500
  openWorldHint: true,
373
501
  },
374
502
  }, async (args) => {
375
- const summary = await createBundle(cfg, {
376
- repos: args.repos,
377
- libraries: args.libraries,
378
- topics: args.topics,
379
- });
380
- const resources = {
381
- startHere: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'START_HERE.md' }),
382
- agents: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'AGENTS.md' }),
383
- overview: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'OVERVIEW.md' }),
384
- manifest: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'manifest.json' }),
385
- };
386
- // Let clients know resources list may have changed.
387
- server.sendResourceListChanged();
388
- const out = {
389
- ...summary,
390
- resources,
391
- };
392
- return {
393
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
394
- structuredContent: out,
395
- };
503
+ try {
504
+ const summary = await createBundle(cfg, {
505
+ repos: args.repos,
506
+ libraries: args.libraries,
507
+ topics: args.topics,
508
+ }, { ifExists: args.ifExists });
509
+ const resources = {
510
+ startHere: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'START_HERE.md' }),
511
+ agents: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'AGENTS.md' }),
512
+ overview: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'OVERVIEW.md' }),
513
+ manifest: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'manifest.json' }),
514
+ };
515
+ // Let clients know resources list may have changed.
516
+ server.sendResourceListChanged();
517
+ const out = {
518
+ ...summary,
519
+ resources,
520
+ };
521
+ return {
522
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
523
+ structuredContent: out,
524
+ };
525
+ }
526
+ catch (err) {
527
+ throw wrapPreflightError(err);
528
+ }
529
+ });
530
+ server.registerTool('preflight_repair_bundle', {
531
+ title: 'Repair bundle (offline)',
532
+ description: 'Validate and repair missing/empty derived bundle artifacts (offline, no fetching): search index, START_HERE.md, AGENTS.md, OVERVIEW.md. Use when: "bundle is broken", "search fails", "index missing", "修复bundle", "重建索引", "修复概览".',
533
+ inputSchema: RepairBundleInputSchema,
534
+ outputSchema: {
535
+ bundleId: z.string(),
536
+ mode: z.enum(['validate', 'repair']),
537
+ repaired: z.boolean(),
538
+ actionsTaken: z.array(z.string()),
539
+ before: z.object({
540
+ isValid: z.boolean(),
541
+ missingComponents: z.array(z.string()),
542
+ }),
543
+ after: z.object({
544
+ isValid: z.boolean(),
545
+ missingComponents: z.array(z.string()),
546
+ }),
547
+ updatedAt: z.string().optional(),
548
+ },
549
+ annotations: {
550
+ openWorldHint: true,
551
+ },
552
+ }, async (args) => {
553
+ try {
554
+ const out = await repairBundle(cfg, args.bundleId, {
555
+ mode: args.mode,
556
+ rebuildIndex: args.rebuildIndex,
557
+ rebuildGuides: args.rebuildGuides,
558
+ rebuildOverview: args.rebuildOverview,
559
+ });
560
+ const summaryLine = out.mode === 'validate'
561
+ ? `VALIDATE ${out.bundleId}: ${out.before.isValid ? 'OK' : 'MISSING'} (${out.before.missingComponents.length} issue(s))`
562
+ : out.repaired
563
+ ? `REPAIRED ${out.bundleId}: ${out.actionsTaken.length} action(s), now ${out.after.isValid ? 'OK' : 'STILL_MISSING'} (${out.after.missingComponents.length} issue(s))`
564
+ : `NOOP ${out.bundleId}: nothing to repair (already OK)`;
565
+ return {
566
+ content: [{ type: 'text', text: summaryLine }],
567
+ structuredContent: out,
568
+ };
569
+ }
570
+ catch (err) {
571
+ throw wrapPreflightError(err);
572
+ }
396
573
  });
397
574
  server.registerTool('preflight_update_bundle', {
398
575
  title: 'Update bundle',
@@ -417,8 +594,9 @@ export async function startServer() {
417
594
  manifest: z.string(),
418
595
  }).optional(),
419
596
  repos: z.array(z.object({
420
- kind: z.enum(['github', 'deepwiki']),
597
+ kind: z.enum(['github', 'local', 'deepwiki']),
421
598
  id: z.string(),
599
+ source: z.enum(['git', 'archive', 'local', 'deepwiki']).optional(),
422
600
  headSha: z.string().optional(),
423
601
  notes: z.array(z.string()).optional(),
424
602
  })).optional(),
@@ -437,41 +615,45 @@ export async function startServer() {
437
615
  openWorldHint: true,
438
616
  },
439
617
  }, async (args) => {
440
- const effectiveDir = await getEffectiveStorageDir(cfg);
441
- const exists = await bundleExists(effectiveDir, args.bundleId);
442
- if (!exists) {
443
- throw new Error(`Bundle not found: ${args.bundleId}`);
444
- }
445
- // checkOnly mode: just check for updates without applying
446
- if (args.checkOnly) {
447
- const { hasUpdates, details } = await checkForUpdates(cfg, args.bundleId);
618
+ try {
619
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
620
+ if (!storageDir) {
621
+ throw new Error(`Bundle not found: ${args.bundleId}`);
622
+ }
623
+ // checkOnly mode: just check for updates without applying
624
+ if (args.checkOnly) {
625
+ const { hasUpdates, details } = await checkForUpdates(cfg, args.bundleId);
626
+ const out = {
627
+ bundleId: args.bundleId,
628
+ changed: hasUpdates,
629
+ checkOnly: true,
630
+ updateDetails: details,
631
+ };
632
+ return {
633
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
634
+ structuredContent: out,
635
+ };
636
+ }
637
+ const { summary, changed } = await updateBundle(cfg, args.bundleId, { force: args.force });
638
+ const resources = {
639
+ startHere: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'START_HERE.md' }),
640
+ agents: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'AGENTS.md' }),
641
+ overview: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'OVERVIEW.md' }),
642
+ manifest: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'manifest.json' }),
643
+ };
448
644
  const out = {
449
- bundleId: args.bundleId,
450
- changed: hasUpdates,
451
- checkOnly: true,
452
- updateDetails: details,
645
+ changed: args.force ? true : changed,
646
+ ...summary,
647
+ resources,
453
648
  };
454
649
  return {
455
650
  content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
456
651
  structuredContent: out,
457
652
  };
458
653
  }
459
- const { summary, changed } = await updateBundle(cfg, args.bundleId, { force: args.force });
460
- const resources = {
461
- startHere: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'START_HERE.md' }),
462
- agents: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'AGENTS.md' }),
463
- overview: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'OVERVIEW.md' }),
464
- manifest: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'manifest.json' }),
465
- };
466
- const out = {
467
- changed: args.force ? true : changed,
468
- ...summary,
469
- resources,
470
- };
471
- return {
472
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
473
- structuredContent: out,
474
- };
654
+ catch (err) {
655
+ throw wrapPreflightError(err);
656
+ }
475
657
  });
476
658
  server.registerTool('preflight_update_all_bundles', {
477
659
  title: 'Update all bundles',
@@ -506,7 +688,7 @@ export async function startServer() {
506
688
  results.push({ bundleId, changed, updatedAt: summary.updatedAt });
507
689
  }
508
690
  catch (err) {
509
- results.push({ bundleId, error: err instanceof Error ? err.message : String(err) });
691
+ results.push({ bundleId, error: wrapPreflightError(err).message });
510
692
  }
511
693
  }
512
694
  const out = {
@@ -538,6 +720,15 @@ export async function startServer() {
538
720
  snippet: z.string(),
539
721
  uri: z.string(),
540
722
  })),
723
+ warnings: z
724
+ .array(z.object({
725
+ bundleId: z.string(),
726
+ kind: z.string(),
727
+ message: z.string(),
728
+ }))
729
+ .optional()
730
+ .describe('Non-fatal per-bundle errors. Use kind to decide whether to repair/update.'),
731
+ warningsTruncated: z.boolean().optional().describe('True if warnings were capped.'),
541
732
  },
542
733
  annotations: {
543
734
  readOnlyHint: true,
@@ -545,57 +736,31 @@ export async function startServer() {
545
736
  }, async (args) => {
546
737
  const effectiveDir = await getEffectiveStorageDir(cfg);
547
738
  const allBundleIds = await listBundles(effectiveDir);
548
- // Filter bundles by tags if specified
549
- let targetBundleIds = allBundleIds;
550
- if (args.tags && args.tags.length > 0) {
551
- targetBundleIds = [];
552
- for (const id of allBundleIds) {
553
- try {
554
- const paths = getBundlePathsForId(effectiveDir, id);
555
- const manifest = await readManifest(paths.manifestPath);
556
- if (manifest.tags && args.tags.some((t) => manifest.tags.includes(t))) {
557
- targetBundleIds.push(id);
558
- }
559
- }
560
- catch {
561
- // Skip bundles with errors
562
- }
563
- }
564
- }
565
- // Search each bundle and collect results
566
- const allHits = [];
567
- for (const bundleId of targetBundleIds) {
568
- try {
739
+ const result = await runSearchByTags({
740
+ bundleIds: allBundleIds,
741
+ query: args.query,
742
+ tags: args.tags,
743
+ scope: args.scope,
744
+ limit: args.limit,
745
+ readManifestForBundleId: async (bundleId) => {
569
746
  const paths = getBundlePathsForId(effectiveDir, bundleId);
570
747
  const manifest = await readManifest(paths.manifestPath);
571
- const bundleHits = searchIndex(paths.searchDbPath, args.query, args.scope, args.limit);
572
- for (const hit of bundleHits) {
573
- allHits.push({
574
- bundleId,
575
- bundleName: manifest.displayName,
576
- kind: hit.kind,
577
- repo: hit.repo,
578
- path: hit.path,
579
- lineNo: hit.lineNo,
580
- snippet: hit.snippet,
581
- uri: toBundleFileUri({ bundleId, relativePath: hit.path }),
582
- });
583
- if (allHits.length >= args.limit)
584
- break;
585
- }
586
- if (allHits.length >= args.limit)
587
- break;
588
- }
589
- catch {
590
- // Skip bundles with search errors
591
- }
592
- }
748
+ return { displayName: manifest.displayName, tags: manifest.tags };
749
+ },
750
+ searchIndexForBundleId: (bundleId, query, scope, limit) => {
751
+ const paths = getBundlePathsForId(effectiveDir, bundleId);
752
+ return searchIndex(paths.searchDbPath, query, scope, limit);
753
+ },
754
+ toUri: (bundleId, p) => toBundleFileUri({ bundleId, relativePath: p }),
755
+ });
593
756
  const out = {
594
757
  query: args.query,
595
758
  tags: args.tags,
596
759
  scope: args.scope,
597
- totalBundlesSearched: targetBundleIds.length,
598
- hits: allHits.slice(0, args.limit),
760
+ totalBundlesSearched: result.totalBundlesSearched,
761
+ hits: result.hits,
762
+ warnings: result.warnings,
763
+ warningsTruncated: result.warningsTruncated,
599
764
  };
600
765
  return {
601
766
  content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
@@ -604,7 +769,7 @@ export async function startServer() {
604
769
  });
605
770
  server.registerTool('preflight_search_bundle', {
606
771
  title: 'Search bundle',
607
- description: 'Full-text search in bundle docs and code. Use when: "search in bundle", "find in repo", "look for X in bundle", "搜索bundle", "在仓库中查找", "搜代码", "搜文档".',
772
+ description: 'Full-text search in bundle docs and code (strictly read-only). If you need to update or repair, call preflight_update_bundle or preflight_repair_bundle explicitly, then search again. Use when: "search in bundle", "find in repo", "look for X in bundle", "搜索bundle", "在仓库中查找", "搜代码", "搜文档".',
608
773
  inputSchema: SearchBundleInputSchema,
609
774
  outputSchema: {
610
775
  bundleId: z.string(),
@@ -618,52 +783,61 @@ export async function startServer() {
618
783
  snippet: z.string(),
619
784
  uri: z.string(),
620
785
  })),
621
- autoUpdated: z.boolean().optional().describe('True if bundle was auto-updated due to ensureFresh.'),
786
+ autoUpdated: z
787
+ .boolean()
788
+ .optional()
789
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-update.'),
790
+ autoRepaired: z
791
+ .boolean()
792
+ .optional()
793
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair.'),
794
+ repairActions: z
795
+ .array(z.string())
796
+ .optional()
797
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair.'),
622
798
  },
623
799
  annotations: {
624
800
  readOnlyHint: true,
625
801
  },
626
802
  }, async (args) => {
627
- const effectiveDir = await getEffectiveStorageDir(cfg);
628
- const exists = await bundleExists(effectiveDir, args.bundleId);
629
- if (!exists) {
630
- throw new Error(`Bundle not found: ${args.bundleId}`);
631
- }
632
- let autoUpdated;
633
- const paths = getBundlePathsForId(effectiveDir, args.bundleId);
634
- // Lazy update: check if bundle is stale when ensureFresh is true.
635
- if (args.ensureFresh) {
636
- const manifest = await readManifest(paths.manifestPath);
637
- const updatedAt = new Date(manifest.updatedAt).getTime();
638
- const ageMs = Date.now() - updatedAt;
639
- const maxAgeMs = (args.maxAgeHours ?? 24) * 60 * 60 * 1000;
640
- if (ageMs > maxAgeMs) {
641
- await updateBundle(cfg, args.bundleId);
642
- autoUpdated = true;
803
+ try {
804
+ // Resolve bundle location across storageDirs (more robust than a single effectiveDir).
805
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
806
+ if (!storageDir) {
807
+ throw new Error(`Bundle not found: ${args.bundleId}`);
808
+ }
809
+ if (args.ensureFresh) {
810
+ throw new Error('ensureFresh is deprecated and not supported in this tool. This tool is strictly read-only. ' +
811
+ 'Call preflight_update_bundle explicitly, then call preflight_search_bundle again.');
643
812
  }
644
- else {
645
- autoUpdated = false;
813
+ if (args.autoRepairIndex) {
814
+ throw new Error('autoRepairIndex is deprecated and not supported in this tool. This tool is strictly read-only. ' +
815
+ 'Call preflight_repair_bundle explicitly, then call preflight_search_bundle again.');
646
816
  }
817
+ const paths = getBundlePathsForId(storageDir, args.bundleId);
818
+ const rawHits = searchIndex(paths.searchDbPath, args.query, args.scope, args.limit);
819
+ const hits = rawHits.map((h) => ({
820
+ ...h,
821
+ uri: toBundleFileUri({ bundleId: args.bundleId, relativePath: h.path }),
822
+ }));
823
+ const out = {
824
+ bundleId: args.bundleId,
825
+ query: args.query,
826
+ scope: args.scope,
827
+ hits,
828
+ };
829
+ return {
830
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
831
+ structuredContent: out,
832
+ };
833
+ }
834
+ catch (err) {
835
+ throw wrapPreflightError(err);
647
836
  }
648
- const hits = searchIndex(paths.searchDbPath, args.query, args.scope, args.limit).map((h) => ({
649
- ...h,
650
- uri: toBundleFileUri({ bundleId: args.bundleId, relativePath: h.path }),
651
- }));
652
- const out = {
653
- bundleId: args.bundleId,
654
- query: args.query,
655
- scope: args.scope,
656
- hits,
657
- autoUpdated,
658
- };
659
- return {
660
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
661
- structuredContent: out,
662
- };
663
837
  });
664
838
  server.registerTool('preflight_verify_claim', {
665
839
  title: 'Verify claim',
666
- description: 'Verify a claim with evidence classification and confidence scoring. Returns supporting/contradicting/related evidence. Use when: "verify this claim", "is this true", "find evidence for", "check if", "验证说法", "找证据", "这个对吗", "有没有依据".',
840
+ description: 'Verify a claim with evidence classification and confidence scoring (strictly read-only). If you need to update or repair, call preflight_update_bundle or preflight_repair_bundle explicitly, then verify again. Returns supporting/contradicting/related evidence. Use when: "verify this claim", "is this true", "find evidence for", "check if", "验证说法", "找证据", "这个对吗", "有没有依据".',
667
841
  inputSchema: VerifyClaimInputSchema,
668
842
  outputSchema: {
669
843
  bundleId: z.string(),
@@ -703,57 +877,102 @@ export async function startServer() {
703
877
  evidenceType: z.enum(['supporting', 'contradicting', 'related']),
704
878
  relevanceScore: z.number(),
705
879
  })).describe('Related but inconclusive evidence'),
706
- autoUpdated: z.boolean().optional().describe('True if bundle was auto-updated due to ensureFresh.'),
880
+ autoUpdated: z
881
+ .boolean()
882
+ .optional()
883
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-update.'),
884
+ autoRepaired: z
885
+ .boolean()
886
+ .optional()
887
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair.'),
888
+ repairActions: z
889
+ .array(z.string())
890
+ .optional()
891
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair.'),
707
892
  },
708
893
  annotations: {
709
894
  readOnlyHint: true,
710
895
  },
711
896
  }, async (args) => {
712
- const effectiveDir = await getEffectiveStorageDir(cfg);
713
- const exists = await bundleExists(effectiveDir, args.bundleId);
714
- if (!exists) {
715
- throw new Error(`Bundle not found: ${args.bundleId}`);
716
- }
717
- let autoUpdated;
718
- const paths = getBundlePathsForId(effectiveDir, args.bundleId);
719
- // Lazy update: check if bundle is stale when ensureFresh is true.
720
- if (args.ensureFresh) {
721
- const manifest = await readManifest(paths.manifestPath);
722
- const updatedAt = new Date(manifest.updatedAt).getTime();
723
- const ageMs = Date.now() - updatedAt;
724
- const maxAgeMs = (args.maxAgeHours ?? 24) * 60 * 60 * 1000;
725
- if (ageMs > maxAgeMs) {
726
- await updateBundle(cfg, args.bundleId);
727
- autoUpdated = true;
897
+ try {
898
+ // Resolve bundle location across storageDirs (more robust than a single effectiveDir).
899
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
900
+ if (!storageDir) {
901
+ throw new Error(`Bundle not found: ${args.bundleId}`);
728
902
  }
729
- else {
730
- autoUpdated = false;
903
+ if (args.ensureFresh) {
904
+ throw new Error('ensureFresh is deprecated and not supported in this tool. This tool is strictly read-only. ' +
905
+ 'Call preflight_update_bundle explicitly, then call preflight_verify_claim again.');
731
906
  }
907
+ if (args.autoRepairIndex) {
908
+ throw new Error('autoRepairIndex is deprecated and not supported in this tool. This tool is strictly read-only. ' +
909
+ 'Call preflight_repair_bundle explicitly, then call preflight_verify_claim again.');
910
+ }
911
+ const paths = getBundlePathsForId(storageDir, args.bundleId);
912
+ const verification = verifyClaimInIndex(paths.searchDbPath, args.claim, args.scope, args.limit);
913
+ // Add URIs to evidence hits
914
+ const addUri = (hit) => ({
915
+ ...hit,
916
+ uri: toBundleFileUri({ bundleId: args.bundleId, relativePath: hit.path }),
917
+ });
918
+ const out = {
919
+ bundleId: args.bundleId,
920
+ claim: args.claim,
921
+ scope: args.scope,
922
+ found: verification.found,
923
+ confidence: verification.confidence,
924
+ confidenceLabel: verification.confidenceLabel,
925
+ summary: verification.summary,
926
+ supporting: verification.supporting.map(addUri),
927
+ contradicting: verification.contradicting.map(addUri),
928
+ related: verification.related.map(addUri),
929
+ };
930
+ return {
931
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
932
+ structuredContent: out,
933
+ };
934
+ }
935
+ catch (err) {
936
+ throw wrapPreflightError(err);
937
+ }
938
+ });
939
+ server.registerTool('preflight_cleanup_orphans', {
940
+ title: 'Cleanup orphan bundles',
941
+ description: 'Remove incomplete or corrupted bundles (bundles without valid manifest.json). Safe to run anytime. Use when: "clean up broken bundles", "remove orphans", "清理孤儿bundle", "清除损坏的bundle".',
942
+ inputSchema: {
943
+ dryRun: z.boolean().default(true).describe('If true, only report orphans without deleting. Set to false to actually delete.'),
944
+ minAgeHours: z.number().default(1).describe('Only clean bundles older than N hours (safety margin to avoid race conditions).'),
945
+ },
946
+ outputSchema: {
947
+ totalFound: z.number(),
948
+ totalCleaned: z.number(),
949
+ details: z.array(z.object({
950
+ storageDir: z.string(),
951
+ found: z.array(z.string()),
952
+ cleaned: z.array(z.string()),
953
+ skipped: z.array(z.object({ bundleId: z.string(), reason: z.string() })),
954
+ })),
955
+ },
956
+ annotations: {
957
+ destructiveHint: true,
958
+ },
959
+ }, async (args) => {
960
+ try {
961
+ const result = await cleanupOrphanBundles(cfg, {
962
+ minAgeHours: args.minAgeHours,
963
+ dryRun: args.dryRun,
964
+ });
965
+ const summary = args.dryRun
966
+ ? `Found ${result.totalFound} orphan bundle(s) (DRY RUN - not deleted)`
967
+ : `Cleaned ${result.totalCleaned} of ${result.totalFound} orphan bundle(s)`;
968
+ return {
969
+ content: [{ type: 'text', text: summary }],
970
+ structuredContent: result,
971
+ };
972
+ }
973
+ catch (err) {
974
+ throw wrapPreflightError(err);
732
975
  }
733
- // Use differentiated verification with confidence scoring
734
- const verification = verifyClaimInIndex(paths.searchDbPath, args.claim, args.scope, args.limit);
735
- // Add URIs to evidence hits
736
- const addUri = (hit) => ({
737
- ...hit,
738
- uri: toBundleFileUri({ bundleId: args.bundleId, relativePath: hit.path }),
739
- });
740
- const out = {
741
- bundleId: args.bundleId,
742
- claim: args.claim,
743
- scope: args.scope,
744
- found: verification.found,
745
- confidence: verification.confidence,
746
- confidenceLabel: verification.confidenceLabel,
747
- summary: verification.summary,
748
- supporting: verification.supporting.map(addUri),
749
- contradicting: verification.contradicting.map(addUri),
750
- related: verification.related.map(addUri),
751
- autoUpdated,
752
- };
753
- return {
754
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
755
- structuredContent: out,
756
- };
757
976
  });
758
977
  // Provide backward-compatible parsing of the same URI via resources/read for clients that bypass templates.
759
978
  // This is a safety net: if a client gives us a fully-specified URI, we can still serve it.