preflight-mcp 0.1.0 → 0.1.1

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,26 @@ 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';
10
12
  const CreateRepoInputSchema = z.union([
11
13
  z.object({
12
14
  kind: z.literal('github'),
13
15
  repo: z.string().describe('GitHub repo in owner/repo form (or github.com/owner/repo URL).'),
14
16
  ref: z.string().optional().describe('Optional git ref (branch/tag).'),
15
17
  }),
18
+ z.object({
19
+ kind: z.literal('local'),
20
+ repo: z
21
+ .string()
22
+ .describe('Logical repo id in owner/repo form (used for storage layout and de-dup).'),
23
+ path: z.string().describe('Local directory path containing the repository files.'),
24
+ ref: z.string().optional().describe('Optional label/ref for the local snapshot.'),
25
+ }),
16
26
  z.object({
17
27
  kind: z.literal('deepwiki'),
18
28
  url: z.string().url().describe('DeepWiki URL (https://deepwiki.com/owner/repo).'),
@@ -22,6 +32,10 @@ const CreateBundleInputSchema = {
22
32
  repos: z.array(CreateRepoInputSchema).min(1).describe('Repositories to ingest into the bundle.'),
23
33
  libraries: z.array(z.string()).optional().describe('Optional library names for Context7 docs ingestion.'),
24
34
  topics: z.array(z.string()).optional().describe('Optional Context7 topic filters (limits fetched docs).'),
35
+ ifExists: z
36
+ .enum(['error', 'returnExisting', 'updateExisting', 'createNew'])
37
+ .default('error')
38
+ .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
39
  };
26
40
  const UpdateBundleInputSchema = {
27
41
  bundleId: z.string().describe('Bundle ID returned by preflight_create_bundle.'),
@@ -39,8 +53,19 @@ const SearchBundleInputSchema = {
39
53
  query: z.string().describe('Search query. Prefix with fts: to use raw FTS syntax.'),
40
54
  scope: z.enum(['docs', 'code', 'all']).default('all').describe('Search scope.'),
41
55
  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).'),
56
+ // Deprecated (kept for backward compatibility): this tool is strictly read-only.
57
+ ensureFresh: z
58
+ .boolean()
59
+ .optional()
60
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-update. Use preflight_update_bundle, then call search again.'),
61
+ maxAgeHours: z
62
+ .number()
63
+ .optional()
64
+ .describe('DEPRECATED. Only used with ensureFresh (which is deprecated).'),
65
+ autoRepairIndex: z
66
+ .boolean()
67
+ .optional()
68
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair. Use preflight_repair_bundle, then call search again.'),
44
69
  };
45
70
  const SearchByTagsInputSchema = {
46
71
  query: z.string().describe('Search query across bundles.'),
@@ -53,8 +78,19 @@ const VerifyClaimInputSchema = {
53
78
  claim: z.string().describe('A claim to look for evidence for (best-effort).'),
54
79
  scope: z.enum(['docs', 'code', 'all']).default('all').describe('Search scope.'),
55
80
  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).'),
81
+ // Deprecated (kept for backward compatibility): this tool is strictly read-only.
82
+ ensureFresh: z
83
+ .boolean()
84
+ .optional()
85
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-update. Use preflight_update_bundle, then call verify again.'),
86
+ maxAgeHours: z
87
+ .number()
88
+ .optional()
89
+ .describe('DEPRECATED. Only used with ensureFresh (which is deprecated).'),
90
+ autoRepairIndex: z
91
+ .boolean()
92
+ .optional()
93
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair. Use preflight_repair_bundle, then call verify again.'),
58
94
  };
59
95
  const ListBundlesInputSchema = {
60
96
  // keep open for future filters
@@ -62,6 +98,13 @@ const ListBundlesInputSchema = {
62
98
  const DeleteBundleInputSchema = {
63
99
  bundleId: z.string().describe('Bundle ID to delete.'),
64
100
  };
101
+ const RepairBundleInputSchema = {
102
+ bundleId: z.string().describe('Bundle ID to repair.'),
103
+ mode: z.enum(['validate', 'repair']).default('repair').describe('validate=report missing components only; repair=fix missing derived artifacts.'),
104
+ rebuildIndex: z.boolean().optional().describe('If true, rebuild search index when missing/empty.'),
105
+ rebuildGuides: z.boolean().optional().describe('If true, rebuild START_HERE.md and AGENTS.md when missing/empty.'),
106
+ rebuildOverview: z.boolean().optional().describe('If true, rebuild OVERVIEW.md when missing/empty.'),
107
+ };
65
108
  const BundleInfoInputSchema = {
66
109
  bundleId: z.string().describe('Bundle ID to get info for.'),
67
110
  };
@@ -73,7 +116,7 @@ export async function startServer() {
73
116
  const cfg = getConfig();
74
117
  const server = new McpServer({
75
118
  name: 'preflight-mcp',
76
- version: '0.1.0',
119
+ version: '0.1.1',
77
120
  description: 'Create evidence-based preflight bundles for repositories (docs + code) with SQLite FTS search.',
78
121
  }, {
79
122
  capabilities: {
@@ -144,74 +187,90 @@ export async function startServer() {
144
187
  });
145
188
  server.registerTool('preflight_list_bundles', {
146
189
  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", "列出仓库".',
190
+ 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
191
  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'),
192
+ filterByTag: z
193
+ .string()
194
+ .optional()
195
+ .describe('Filter by tag (e.g., "mcp", "agents", "web-scraping").'),
196
+ limit: z
197
+ .number()
198
+ .int()
199
+ .min(1)
200
+ .max(200)
201
+ .default(50)
202
+ .describe('Max number of bundles to return.'),
203
+ maxItemsPerList: z
204
+ .number()
205
+ .int()
206
+ .min(1)
207
+ .max(50)
208
+ .default(10)
209
+ .describe('Max repos/tags to include per bundle to keep output compact.'),
151
210
  },
152
211
  outputSchema: {
153
212
  bundles: z.array(z.object({
154
213
  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(),
214
+ displayName: z.string(),
215
+ repos: z.array(z.string()),
216
+ tags: z.array(z.string()),
161
217
  })),
162
- grouped: z.record(z.string(), z.array(z.string())).optional(),
163
218
  },
164
219
  annotations: {
165
220
  readOnlyHint: true,
166
221
  },
167
222
  }, async (args) => {
168
223
  const effectiveDir = await getEffectiveStorageDir(cfg);
169
- const ids = await listBundles(effectiveDir);
170
- // Read manifests for each bundle
171
- const bundlesWithMeta = [];
224
+ const ids = (await listBundles(effectiveDir)).slice(0, args.limit);
225
+ const capList = (items, max) => {
226
+ if (items.length <= max)
227
+ return items;
228
+ const keep = items.slice(0, max);
229
+ keep.push(`...(+${items.length - max})`);
230
+ return keep;
231
+ };
232
+ const bundlesInternal = [];
172
233
  for (const id of ids) {
173
234
  try {
174
235
  const paths = getBundlePathsForId(effectiveDir, id);
175
236
  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({
237
+ const reposRaw = (manifest.repos ?? []).map((r) => r.id).filter(Boolean);
238
+ const tagsFull = (manifest.tags ?? []).map(String);
239
+ const displayName = (manifest.displayName && manifest.displayName.trim()) ||
240
+ (reposRaw[0] && reposRaw[0].trim()) ||
241
+ '(unnamed)';
242
+ bundlesInternal.push({
180
243
  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,
244
+ displayName,
245
+ repos: capList(reposRaw, args.maxItemsPerList),
246
+ tags: capList(tagsFull, args.maxItemsPerList),
247
+ tagsFull,
187
248
  });
188
249
  }
189
250
  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);
251
+ // Keep the bundleId visible even if the manifest is missing/corrupt.
252
+ bundlesInternal.push({
253
+ bundleId: id,
254
+ displayName: '(unreadable manifest)',
255
+ repos: [],
256
+ tags: [],
257
+ tagsFull: [],
258
+ });
207
259
  }
208
260
  }
209
- const out = {
210
- bundles: filtered,
211
- grouped,
212
- };
261
+ const filteredInternal = args.filterByTag
262
+ ? bundlesInternal.filter((b) => b.tagsFull.includes(args.filterByTag))
263
+ : bundlesInternal;
264
+ const filtered = filteredInternal.map(({ tagsFull: _tagsFull, ...b }) => b);
265
+ const out = { bundles: filtered };
266
+ // Stable human-readable format for UI logs.
267
+ const lines = filtered.map((b) => {
268
+ const repos = b.repos.length ? b.repos.join(', ') : '(none)';
269
+ const tags = b.tags.length ? b.tags.join(', ') : '(none)';
270
+ return `${b.bundleId} | ${b.displayName} | repos: ${repos} | tags: ${tags}`;
271
+ });
213
272
  return {
214
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
273
+ content: [{ type: 'text', text: lines.join('\n') || '(no bundles)' }],
215
274
  structuredContent: out,
216
275
  };
217
276
  });
@@ -228,23 +287,27 @@ export async function startServer() {
228
287
  readOnlyHint: true,
229
288
  },
230
289
  }, 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}`);
290
+ try {
291
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
292
+ if (!storageDir) {
293
+ throw new Error(`Bundle not found: ${args.bundleId}`);
294
+ }
295
+ const bundleRoot = getBundlePathsForId(storageDir, args.bundleId).rootDir;
296
+ const absPath = safeJoin(bundleRoot, args.file);
297
+ const content = await fs.readFile(absPath, 'utf8');
298
+ const out = {
299
+ bundleId: args.bundleId,
300
+ file: args.file,
301
+ content,
302
+ };
303
+ return {
304
+ content: [{ type: 'text', text: content }],
305
+ structuredContent: out,
306
+ };
307
+ }
308
+ catch (err) {
309
+ throw wrapPreflightError(err);
235
310
  }
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
311
  });
249
312
  server.registerTool('preflight_delete_bundle', {
250
313
  title: 'Delete bundle',
@@ -258,16 +321,21 @@ export async function startServer() {
258
321
  destructiveHint: true,
259
322
  },
260
323
  }, async (args) => {
261
- const deleted = await clearBundleMulti(cfg.storageDirs, args.bundleId);
262
- if (!deleted) {
263
- throw new Error(`Bundle not found: ${args.bundleId}`);
324
+ try {
325
+ const deleted = await clearBundleMulti(cfg.storageDirs, args.bundleId);
326
+ if (!deleted) {
327
+ throw new Error(`Bundle not found: ${args.bundleId}`);
328
+ }
329
+ server.sendResourceListChanged();
330
+ const out = { deleted: true, bundleId: args.bundleId };
331
+ return {
332
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
333
+ structuredContent: out,
334
+ };
335
+ }
336
+ catch (err) {
337
+ throw wrapPreflightError(err);
264
338
  }
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
339
  });
272
340
  server.registerTool('preflight_bundle_info', {
273
341
  title: 'Bundle info',
@@ -278,8 +346,9 @@ export async function startServer() {
278
346
  createdAt: z.string(),
279
347
  updatedAt: z.string(),
280
348
  repos: z.array(z.object({
281
- kind: z.enum(['github', 'deepwiki']),
349
+ kind: z.enum(['github', 'local', 'deepwiki']),
282
350
  id: z.string(),
351
+ source: z.enum(['git', 'archive', 'local', 'deepwiki']).optional(),
283
352
  headSha: z.string().optional(),
284
353
  fetchedAt: z.string().optional(),
285
354
  notes: z.array(z.string()).optional(),
@@ -310,36 +379,89 @@ export async function startServer() {
310
379
  readOnlyHint: true,
311
380
  },
312
381
  }, 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}`);
382
+ try {
383
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
384
+ if (!storageDir) {
385
+ throw new Error(`Bundle not found: ${args.bundleId}`);
386
+ }
387
+ const paths = getBundlePathsForId(storageDir, args.bundleId);
388
+ const manifest = await readManifest(paths.manifestPath);
389
+ const resources = {
390
+ startHere: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'START_HERE.md' }),
391
+ agents: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'AGENTS.md' }),
392
+ overview: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'OVERVIEW.md' }),
393
+ manifest: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'manifest.json' }),
394
+ };
395
+ const out = {
396
+ bundleId: manifest.bundleId,
397
+ createdAt: manifest.createdAt,
398
+ updatedAt: manifest.updatedAt,
399
+ repos: manifest.repos,
400
+ libraries: manifest.libraries,
401
+ index: manifest.index,
402
+ resources,
403
+ };
404
+ return {
405
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
406
+ structuredContent: out,
407
+ };
408
+ }
409
+ catch (err) {
410
+ throw wrapPreflightError(err);
411
+ }
412
+ });
413
+ server.registerTool('preflight_find_bundle', {
414
+ title: 'Find existing bundle',
415
+ 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", "这个项目是否已索引".',
416
+ inputSchema: {
417
+ repos: z.array(CreateRepoInputSchema).min(1),
418
+ libraries: z.array(z.string()).optional(),
419
+ topics: z.array(z.string()).optional(),
420
+ },
421
+ outputSchema: {
422
+ found: z.boolean(),
423
+ bundleId: z.string().optional(),
424
+ fingerprint: z.string(),
425
+ },
426
+ annotations: {
427
+ readOnlyHint: true,
428
+ },
429
+ }, async (args) => {
430
+ try {
431
+ const fingerprint = computeCreateInputFingerprint({
432
+ repos: args.repos,
433
+ libraries: args.libraries,
434
+ topics: args.topics,
435
+ });
436
+ const bundleId = await findBundleByInputs(cfg, {
437
+ repos: args.repos,
438
+ libraries: args.libraries,
439
+ topics: args.topics,
440
+ });
441
+ const out = {
442
+ found: !!bundleId,
443
+ bundleId: bundleId ?? undefined,
444
+ fingerprint,
445
+ };
446
+ return {
447
+ content: [
448
+ {
449
+ type: 'text',
450
+ text: out.found
451
+ ? `FOUND ${out.bundleId} (fingerprint=${out.fingerprint})`
452
+ : `NOT_FOUND (fingerprint=${out.fingerprint})`,
453
+ },
454
+ ],
455
+ structuredContent: out,
456
+ };
457
+ }
458
+ catch (err) {
459
+ throw wrapPreflightError(err);
317
460
  }
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
461
  });
340
462
  server.registerTool('preflight_create_bundle', {
341
463
  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项目", "学习这个项目".',
464
+ 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
465
  inputSchema: CreateBundleInputSchema,
344
466
  outputSchema: {
345
467
  bundleId: z.string(),
@@ -352,8 +474,9 @@ export async function startServer() {
352
474
  manifest: z.string(),
353
475
  }),
354
476
  repos: z.array(z.object({
355
- kind: z.enum(['github', 'deepwiki']),
477
+ kind: z.enum(['github', 'local', 'deepwiki']),
356
478
  id: z.string(),
479
+ source: z.enum(['git', 'archive', 'local', 'deepwiki']).optional(),
357
480
  headSha: z.string().optional(),
358
481
  notes: z.array(z.string()).optional(),
359
482
  })),
@@ -372,27 +495,76 @@ export async function startServer() {
372
495
  openWorldHint: true,
373
496
  },
374
497
  }, 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
- };
498
+ try {
499
+ const summary = await createBundle(cfg, {
500
+ repos: args.repos,
501
+ libraries: args.libraries,
502
+ topics: args.topics,
503
+ }, { ifExists: args.ifExists });
504
+ const resources = {
505
+ startHere: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'START_HERE.md' }),
506
+ agents: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'AGENTS.md' }),
507
+ overview: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'OVERVIEW.md' }),
508
+ manifest: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'manifest.json' }),
509
+ };
510
+ // Let clients know resources list may have changed.
511
+ server.sendResourceListChanged();
512
+ const out = {
513
+ ...summary,
514
+ resources,
515
+ };
516
+ return {
517
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
518
+ structuredContent: out,
519
+ };
520
+ }
521
+ catch (err) {
522
+ throw wrapPreflightError(err);
523
+ }
524
+ });
525
+ server.registerTool('preflight_repair_bundle', {
526
+ title: 'Repair bundle (offline)',
527
+ 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", "重建索引", "修复概览".',
528
+ inputSchema: RepairBundleInputSchema,
529
+ outputSchema: {
530
+ bundleId: z.string(),
531
+ mode: z.enum(['validate', 'repair']),
532
+ repaired: z.boolean(),
533
+ actionsTaken: z.array(z.string()),
534
+ before: z.object({
535
+ isValid: z.boolean(),
536
+ missingComponents: z.array(z.string()),
537
+ }),
538
+ after: z.object({
539
+ isValid: z.boolean(),
540
+ missingComponents: z.array(z.string()),
541
+ }),
542
+ updatedAt: z.string().optional(),
543
+ },
544
+ annotations: {
545
+ openWorldHint: true,
546
+ },
547
+ }, async (args) => {
548
+ try {
549
+ const out = await repairBundle(cfg, args.bundleId, {
550
+ mode: args.mode,
551
+ rebuildIndex: args.rebuildIndex,
552
+ rebuildGuides: args.rebuildGuides,
553
+ rebuildOverview: args.rebuildOverview,
554
+ });
555
+ const summaryLine = out.mode === 'validate'
556
+ ? `VALIDATE ${out.bundleId}: ${out.before.isValid ? 'OK' : 'MISSING'} (${out.before.missingComponents.length} issue(s))`
557
+ : out.repaired
558
+ ? `REPAIRED ${out.bundleId}: ${out.actionsTaken.length} action(s), now ${out.after.isValid ? 'OK' : 'STILL_MISSING'} (${out.after.missingComponents.length} issue(s))`
559
+ : `NOOP ${out.bundleId}: nothing to repair (already OK)`;
560
+ return {
561
+ content: [{ type: 'text', text: summaryLine }],
562
+ structuredContent: out,
563
+ };
564
+ }
565
+ catch (err) {
566
+ throw wrapPreflightError(err);
567
+ }
396
568
  });
397
569
  server.registerTool('preflight_update_bundle', {
398
570
  title: 'Update bundle',
@@ -417,8 +589,9 @@ export async function startServer() {
417
589
  manifest: z.string(),
418
590
  }).optional(),
419
591
  repos: z.array(z.object({
420
- kind: z.enum(['github', 'deepwiki']),
592
+ kind: z.enum(['github', 'local', 'deepwiki']),
421
593
  id: z.string(),
594
+ source: z.enum(['git', 'archive', 'local', 'deepwiki']).optional(),
422
595
  headSha: z.string().optional(),
423
596
  notes: z.array(z.string()).optional(),
424
597
  })).optional(),
@@ -437,41 +610,45 @@ export async function startServer() {
437
610
  openWorldHint: true,
438
611
  },
439
612
  }, 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);
613
+ try {
614
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
615
+ if (!storageDir) {
616
+ throw new Error(`Bundle not found: ${args.bundleId}`);
617
+ }
618
+ // checkOnly mode: just check for updates without applying
619
+ if (args.checkOnly) {
620
+ const { hasUpdates, details } = await checkForUpdates(cfg, args.bundleId);
621
+ const out = {
622
+ bundleId: args.bundleId,
623
+ changed: hasUpdates,
624
+ checkOnly: true,
625
+ updateDetails: details,
626
+ };
627
+ return {
628
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
629
+ structuredContent: out,
630
+ };
631
+ }
632
+ const { summary, changed } = await updateBundle(cfg, args.bundleId, { force: args.force });
633
+ const resources = {
634
+ startHere: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'START_HERE.md' }),
635
+ agents: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'AGENTS.md' }),
636
+ overview: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'OVERVIEW.md' }),
637
+ manifest: toBundleFileUri({ bundleId: summary.bundleId, relativePath: 'manifest.json' }),
638
+ };
448
639
  const out = {
449
- bundleId: args.bundleId,
450
- changed: hasUpdates,
451
- checkOnly: true,
452
- updateDetails: details,
640
+ changed: args.force ? true : changed,
641
+ ...summary,
642
+ resources,
453
643
  };
454
644
  return {
455
645
  content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
456
646
  structuredContent: out,
457
647
  };
458
648
  }
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
- };
649
+ catch (err) {
650
+ throw wrapPreflightError(err);
651
+ }
475
652
  });
476
653
  server.registerTool('preflight_update_all_bundles', {
477
654
  title: 'Update all bundles',
@@ -506,7 +683,7 @@ export async function startServer() {
506
683
  results.push({ bundleId, changed, updatedAt: summary.updatedAt });
507
684
  }
508
685
  catch (err) {
509
- results.push({ bundleId, error: err instanceof Error ? err.message : String(err) });
686
+ results.push({ bundleId, error: wrapPreflightError(err).message });
510
687
  }
511
688
  }
512
689
  const out = {
@@ -538,6 +715,15 @@ export async function startServer() {
538
715
  snippet: z.string(),
539
716
  uri: z.string(),
540
717
  })),
718
+ warnings: z
719
+ .array(z.object({
720
+ bundleId: z.string(),
721
+ kind: z.string(),
722
+ message: z.string(),
723
+ }))
724
+ .optional()
725
+ .describe('Non-fatal per-bundle errors. Use kind to decide whether to repair/update.'),
726
+ warningsTruncated: z.boolean().optional().describe('True if warnings were capped.'),
541
727
  },
542
728
  annotations: {
543
729
  readOnlyHint: true,
@@ -545,57 +731,31 @@ export async function startServer() {
545
731
  }, async (args) => {
546
732
  const effectiveDir = await getEffectiveStorageDir(cfg);
547
733
  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 {
734
+ const result = await runSearchByTags({
735
+ bundleIds: allBundleIds,
736
+ query: args.query,
737
+ tags: args.tags,
738
+ scope: args.scope,
739
+ limit: args.limit,
740
+ readManifestForBundleId: async (bundleId) => {
569
741
  const paths = getBundlePathsForId(effectiveDir, bundleId);
570
742
  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
- }
743
+ return { displayName: manifest.displayName, tags: manifest.tags };
744
+ },
745
+ searchIndexForBundleId: (bundleId, query, scope, limit) => {
746
+ const paths = getBundlePathsForId(effectiveDir, bundleId);
747
+ return searchIndex(paths.searchDbPath, query, scope, limit);
748
+ },
749
+ toUri: (bundleId, p) => toBundleFileUri({ bundleId, relativePath: p }),
750
+ });
593
751
  const out = {
594
752
  query: args.query,
595
753
  tags: args.tags,
596
754
  scope: args.scope,
597
- totalBundlesSearched: targetBundleIds.length,
598
- hits: allHits.slice(0, args.limit),
755
+ totalBundlesSearched: result.totalBundlesSearched,
756
+ hits: result.hits,
757
+ warnings: result.warnings,
758
+ warningsTruncated: result.warningsTruncated,
599
759
  };
600
760
  return {
601
761
  content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
@@ -604,7 +764,7 @@ export async function startServer() {
604
764
  });
605
765
  server.registerTool('preflight_search_bundle', {
606
766
  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", "在仓库中查找", "搜代码", "搜文档".',
767
+ 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
768
  inputSchema: SearchBundleInputSchema,
609
769
  outputSchema: {
610
770
  bundleId: z.string(),
@@ -618,52 +778,61 @@ export async function startServer() {
618
778
  snippet: z.string(),
619
779
  uri: z.string(),
620
780
  })),
621
- autoUpdated: z.boolean().optional().describe('True if bundle was auto-updated due to ensureFresh.'),
781
+ autoUpdated: z
782
+ .boolean()
783
+ .optional()
784
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-update.'),
785
+ autoRepaired: z
786
+ .boolean()
787
+ .optional()
788
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair.'),
789
+ repairActions: z
790
+ .array(z.string())
791
+ .optional()
792
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair.'),
622
793
  },
623
794
  annotations: {
624
795
  readOnlyHint: true,
625
796
  },
626
797
  }, 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;
798
+ try {
799
+ // Resolve bundle location across storageDirs (more robust than a single effectiveDir).
800
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
801
+ if (!storageDir) {
802
+ throw new Error(`Bundle not found: ${args.bundleId}`);
803
+ }
804
+ if (args.ensureFresh) {
805
+ throw new Error('ensureFresh is deprecated and not supported in this tool. This tool is strictly read-only. ' +
806
+ 'Call preflight_update_bundle explicitly, then call preflight_search_bundle again.');
643
807
  }
644
- else {
645
- autoUpdated = false;
808
+ if (args.autoRepairIndex) {
809
+ throw new Error('autoRepairIndex is deprecated and not supported in this tool. This tool is strictly read-only. ' +
810
+ 'Call preflight_repair_bundle explicitly, then call preflight_search_bundle again.');
646
811
  }
812
+ const paths = getBundlePathsForId(storageDir, args.bundleId);
813
+ const rawHits = searchIndex(paths.searchDbPath, args.query, args.scope, args.limit);
814
+ const hits = rawHits.map((h) => ({
815
+ ...h,
816
+ uri: toBundleFileUri({ bundleId: args.bundleId, relativePath: h.path }),
817
+ }));
818
+ const out = {
819
+ bundleId: args.bundleId,
820
+ query: args.query,
821
+ scope: args.scope,
822
+ hits,
823
+ };
824
+ return {
825
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
826
+ structuredContent: out,
827
+ };
828
+ }
829
+ catch (err) {
830
+ throw wrapPreflightError(err);
647
831
  }
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
832
  });
664
833
  server.registerTool('preflight_verify_claim', {
665
834
  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", "验证说法", "找证据", "这个对吗", "有没有依据".',
835
+ 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
836
  inputSchema: VerifyClaimInputSchema,
668
837
  outputSchema: {
669
838
  bundleId: z.string(),
@@ -703,57 +872,64 @@ export async function startServer() {
703
872
  evidenceType: z.enum(['supporting', 'contradicting', 'related']),
704
873
  relevanceScore: z.number(),
705
874
  })).describe('Related but inconclusive evidence'),
706
- autoUpdated: z.boolean().optional().describe('True if bundle was auto-updated due to ensureFresh.'),
875
+ autoUpdated: z
876
+ .boolean()
877
+ .optional()
878
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-update.'),
879
+ autoRepaired: z
880
+ .boolean()
881
+ .optional()
882
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair.'),
883
+ repairActions: z
884
+ .array(z.string())
885
+ .optional()
886
+ .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair.'),
707
887
  },
708
888
  annotations: {
709
889
  readOnlyHint: true,
710
890
  },
711
891
  }, 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;
892
+ try {
893
+ // Resolve bundle location across storageDirs (more robust than a single effectiveDir).
894
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
895
+ if (!storageDir) {
896
+ throw new Error(`Bundle not found: ${args.bundleId}`);
728
897
  }
729
- else {
730
- autoUpdated = false;
898
+ if (args.ensureFresh) {
899
+ throw new Error('ensureFresh is deprecated and not supported in this tool. This tool is strictly read-only. ' +
900
+ 'Call preflight_update_bundle explicitly, then call preflight_verify_claim again.');
731
901
  }
902
+ if (args.autoRepairIndex) {
903
+ throw new Error('autoRepairIndex is deprecated and not supported in this tool. This tool is strictly read-only. ' +
904
+ 'Call preflight_repair_bundle explicitly, then call preflight_verify_claim again.');
905
+ }
906
+ const paths = getBundlePathsForId(storageDir, args.bundleId);
907
+ const verification = verifyClaimInIndex(paths.searchDbPath, args.claim, args.scope, args.limit);
908
+ // Add URIs to evidence hits
909
+ const addUri = (hit) => ({
910
+ ...hit,
911
+ uri: toBundleFileUri({ bundleId: args.bundleId, relativePath: hit.path }),
912
+ });
913
+ const out = {
914
+ bundleId: args.bundleId,
915
+ claim: args.claim,
916
+ scope: args.scope,
917
+ found: verification.found,
918
+ confidence: verification.confidence,
919
+ confidenceLabel: verification.confidenceLabel,
920
+ summary: verification.summary,
921
+ supporting: verification.supporting.map(addUri),
922
+ contradicting: verification.contradicting.map(addUri),
923
+ related: verification.related.map(addUri),
924
+ };
925
+ return {
926
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
927
+ structuredContent: out,
928
+ };
929
+ }
930
+ catch (err) {
931
+ throw wrapPreflightError(err);
732
932
  }
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
933
  });
758
934
  // Provide backward-compatible parsing of the same URI via resources/read for clients that bypass templates.
759
935
  // This is a safety net: if a client gives us a fully-specified URI, we can still serve it.