preflight-mcp 0.1.0

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 ADDED
@@ -0,0 +1,778 @@
1
+ import fs from 'node:fs/promises';
2
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import * as z from 'zod';
5
+ import { getConfig } from './config.js';
6
+ import { bundleExists, checkForUpdates, clearBundleMulti, createBundle, getBundlePathsForId, getEffectiveStorageDir, listBundles, updateBundle, } from './bundle/service.js';
7
+ import { readManifest } from './bundle/manifest.js';
8
+ import { safeJoin, toBundleFileUri } from './mcp/uris.js';
9
+ import { searchIndex, verifyClaimInIndex } from './search/sqliteFts.js';
10
+ const CreateRepoInputSchema = z.union([
11
+ z.object({
12
+ kind: z.literal('github'),
13
+ repo: z.string().describe('GitHub repo in owner/repo form (or github.com/owner/repo URL).'),
14
+ ref: z.string().optional().describe('Optional git ref (branch/tag).'),
15
+ }),
16
+ z.object({
17
+ kind: z.literal('deepwiki'),
18
+ url: z.string().url().describe('DeepWiki URL (https://deepwiki.com/owner/repo).'),
19
+ }),
20
+ ]);
21
+ const CreateBundleInputSchema = {
22
+ repos: z.array(CreateRepoInputSchema).min(1).describe('Repositories to ingest into the bundle.'),
23
+ libraries: z.array(z.string()).optional().describe('Optional library names for Context7 docs ingestion.'),
24
+ topics: z.array(z.string()).optional().describe('Optional Context7 topic filters (limits fetched docs).'),
25
+ };
26
+ const UpdateBundleInputSchema = {
27
+ bundleId: z.string().describe('Bundle ID returned by preflight_create_bundle.'),
28
+ checkOnly: z.boolean().optional().describe('If true, only check if updates are available without applying them.'),
29
+ force: z.boolean().optional().describe('If true, force rebuild index even if no changes detected.'),
30
+ };
31
+ const UpdateAllBundlesInputSchema = {
32
+ bundleIds: z
33
+ .array(z.string())
34
+ .optional()
35
+ .describe('Optional bundle IDs to update. If omitted, updates all bundles in storage.'),
36
+ };
37
+ const SearchBundleInputSchema = {
38
+ bundleId: z.string().describe('Bundle ID to search.'),
39
+ query: z.string().describe('Search query. Prefix with fts: to use raw FTS syntax.'),
40
+ scope: z.enum(['docs', 'code', 'all']).default('all').describe('Search scope.'),
41
+ 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).'),
44
+ };
45
+ const SearchByTagsInputSchema = {
46
+ query: z.string().describe('Search query across bundles.'),
47
+ tags: z.array(z.string()).optional().describe('Filter by tags (e.g., ["mcp", "agents"]). Searches only matching bundles.'),
48
+ scope: z.enum(['docs', 'code', 'all']).default('all').describe('Search scope.'),
49
+ limit: z.number().int().min(1).max(200).default(50).describe('Max total hits across all bundles.'),
50
+ };
51
+ const VerifyClaimInputSchema = {
52
+ bundleId: z.string().describe('Bundle ID to verify against.'),
53
+ claim: z.string().describe('A claim to look for evidence for (best-effort).'),
54
+ scope: z.enum(['docs', 'code', 'all']).default('all').describe('Search scope.'),
55
+ 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).'),
58
+ };
59
+ const ListBundlesInputSchema = {
60
+ // keep open for future filters
61
+ };
62
+ const DeleteBundleInputSchema = {
63
+ bundleId: z.string().describe('Bundle ID to delete.'),
64
+ };
65
+ const BundleInfoInputSchema = {
66
+ bundleId: z.string().describe('Bundle ID to get info for.'),
67
+ };
68
+ const ReadFileInputSchema = {
69
+ bundleId: z.string().describe('Bundle ID.'),
70
+ 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'),
71
+ };
72
+ export async function startServer() {
73
+ const cfg = getConfig();
74
+ const server = new McpServer({
75
+ name: 'preflight-mcp',
76
+ version: '0.1.0',
77
+ description: 'Create evidence-based preflight bundles for repositories (docs + code) with SQLite FTS search.',
78
+ }, {
79
+ capabilities: {
80
+ resources: {
81
+ // We can emit list changed notifications when new bundles appear.
82
+ listChanged: true,
83
+ },
84
+ },
85
+ });
86
+ // Resource template to read any file inside a bundle.
87
+ // URI format: preflight://bundle/{bundleId}/file/{encodedPath}
88
+ server.registerResource('bundle-file', new ResourceTemplate('preflight://bundle/{bundleId}/file/{encodedPath}', {
89
+ list: undefined,
90
+ complete: {
91
+ bundleId: async (value) => {
92
+ const effectiveDir = await getEffectiveStorageDir(cfg);
93
+ const ids = await listBundles(effectiveDir);
94
+ return ids.filter((id) => id.startsWith(value)).slice(0, 50);
95
+ },
96
+ },
97
+ }), {
98
+ title: 'Preflight bundle file',
99
+ description: 'Reads a specific file from a preflight bundle by bundleId and encoded path.',
100
+ mimeType: 'text/plain',
101
+ }, async (uri, vars) => {
102
+ const bundleId = String(vars['bundleId'] ?? '');
103
+ const encodedPath = String(vars['encodedPath'] ?? '');
104
+ const rel = decodeURIComponent(encodedPath);
105
+ const effectiveDir = await getEffectiveStorageDir(cfg);
106
+ const bundleRoot = getBundlePathsForId(effectiveDir, bundleId).rootDir;
107
+ const absPath = safeJoin(bundleRoot, rel);
108
+ const text = await fs.readFile(absPath, 'utf8');
109
+ const mimeType = absPath.endsWith('.md') ? 'text/markdown' : 'text/plain';
110
+ return {
111
+ contents: [
112
+ {
113
+ uri: uri.href,
114
+ mimeType,
115
+ text,
116
+ },
117
+ ],
118
+ };
119
+ });
120
+ // A small static resource that lists bundles.
121
+ server.registerResource('bundles-index', 'preflight://bundles', {
122
+ title: 'Preflight bundles index',
123
+ description: 'Lists available bundle IDs and their main entry files.',
124
+ mimeType: 'application/json',
125
+ }, async () => {
126
+ const effectiveDir = await getEffectiveStorageDir(cfg);
127
+ const ids = await listBundles(effectiveDir);
128
+ const items = ids.map((id) => ({
129
+ bundleId: id,
130
+ startHere: toBundleFileUri({ bundleId: id, relativePath: 'START_HERE.md' }),
131
+ agents: toBundleFileUri({ bundleId: id, relativePath: 'AGENTS.md' }),
132
+ overview: toBundleFileUri({ bundleId: id, relativePath: 'OVERVIEW.md' }),
133
+ manifest: toBundleFileUri({ bundleId: id, relativePath: 'manifest.json' }),
134
+ }));
135
+ return {
136
+ contents: [
137
+ {
138
+ uri: 'preflight://bundles',
139
+ mimeType: 'application/json',
140
+ text: JSON.stringify({ bundles: items }, null, 2),
141
+ },
142
+ ],
143
+ };
144
+ });
145
+ server.registerTool('preflight_list_bundles', {
146
+ 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", "列出仓库".',
148
+ 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'),
151
+ },
152
+ outputSchema: {
153
+ bundles: z.array(z.object({
154
+ 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(),
161
+ })),
162
+ grouped: z.record(z.string(), z.array(z.string())).optional(),
163
+ },
164
+ annotations: {
165
+ readOnlyHint: true,
166
+ },
167
+ }, async (args) => {
168
+ const effectiveDir = await getEffectiveStorageDir(cfg);
169
+ const ids = await listBundles(effectiveDir);
170
+ // Read manifests for each bundle
171
+ const bundlesWithMeta = [];
172
+ for (const id of ids) {
173
+ try {
174
+ const paths = getBundlePathsForId(effectiveDir, id);
175
+ 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({
180
+ 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,
187
+ });
188
+ }
189
+ 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);
207
+ }
208
+ }
209
+ const out = {
210
+ bundles: filtered,
211
+ grouped,
212
+ };
213
+ return {
214
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
215
+ structuredContent: out,
216
+ };
217
+ });
218
+ server.registerTool('preflight_read_file', {
219
+ title: 'Read bundle file',
220
+ description: 'Read a file from bundle. Use when: "show overview", "read file", "查看概览", "项目概览", "看README", "查看文档". Common files: OVERVIEW.md, START_HERE.md, AGENTS.md.',
221
+ inputSchema: ReadFileInputSchema,
222
+ outputSchema: {
223
+ bundleId: z.string(),
224
+ file: z.string(),
225
+ content: z.string(),
226
+ },
227
+ annotations: {
228
+ readOnlyHint: true,
229
+ },
230
+ }, 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}`);
235
+ }
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
+ });
249
+ server.registerTool('preflight_delete_bundle', {
250
+ title: 'Delete bundle',
251
+ description: 'Delete/remove a bundle permanently. Use when: "delete bundle", "remove bundle", "清除bundle", "删除索引", "移除仓库".',
252
+ inputSchema: DeleteBundleInputSchema,
253
+ outputSchema: {
254
+ deleted: z.boolean(),
255
+ bundleId: z.string(),
256
+ },
257
+ annotations: {
258
+ destructiveHint: true,
259
+ },
260
+ }, async (args) => {
261
+ const deleted = await clearBundleMulti(cfg.storageDirs, args.bundleId);
262
+ if (!deleted) {
263
+ throw new Error(`Bundle not found: ${args.bundleId}`);
264
+ }
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
+ });
272
+ server.registerTool('preflight_bundle_info', {
273
+ title: 'Bundle info',
274
+ description: 'Get bundle details: repos, update time, stats. Use when: "bundle info", "show bundle details", "what\'s in this bundle", "bundle状态", "查看bundle详情", "仓库信息".',
275
+ inputSchema: BundleInfoInputSchema,
276
+ outputSchema: {
277
+ bundleId: z.string(),
278
+ createdAt: z.string(),
279
+ updatedAt: z.string(),
280
+ repos: z.array(z.object({
281
+ kind: z.enum(['github', 'deepwiki']),
282
+ id: z.string(),
283
+ headSha: z.string().optional(),
284
+ fetchedAt: z.string().optional(),
285
+ notes: z.array(z.string()).optional(),
286
+ })),
287
+ libraries: z
288
+ .array(z.object({
289
+ kind: z.literal('context7'),
290
+ input: z.string(),
291
+ id: z.string().optional(),
292
+ fetchedAt: z.string(),
293
+ notes: z.array(z.string()).optional(),
294
+ files: z.array(z.string()).optional(),
295
+ }))
296
+ .optional(),
297
+ index: z.object({
298
+ backend: z.string(),
299
+ includeDocs: z.boolean(),
300
+ includeCode: z.boolean(),
301
+ }),
302
+ resources: z.object({
303
+ startHere: z.string(),
304
+ agents: z.string(),
305
+ overview: z.string(),
306
+ manifest: z.string(),
307
+ }),
308
+ },
309
+ annotations: {
310
+ readOnlyHint: true,
311
+ },
312
+ }, 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}`);
317
+ }
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
+ });
340
+ server.registerTool('preflight_create_bundle', {
341
+ 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项目", "学习这个项目".',
343
+ inputSchema: CreateBundleInputSchema,
344
+ outputSchema: {
345
+ bundleId: z.string(),
346
+ createdAt: z.string(),
347
+ updatedAt: z.string(),
348
+ resources: z.object({
349
+ startHere: z.string(),
350
+ agents: z.string(),
351
+ overview: z.string(),
352
+ manifest: z.string(),
353
+ }),
354
+ repos: z.array(z.object({
355
+ kind: z.enum(['github', 'deepwiki']),
356
+ id: z.string(),
357
+ headSha: z.string().optional(),
358
+ notes: z.array(z.string()).optional(),
359
+ })),
360
+ libraries: z
361
+ .array(z.object({
362
+ kind: z.literal('context7'),
363
+ input: z.string(),
364
+ id: z.string().optional(),
365
+ fetchedAt: z.string(),
366
+ notes: z.array(z.string()).optional(),
367
+ files: z.array(z.string()).optional(),
368
+ }))
369
+ .optional(),
370
+ },
371
+ annotations: {
372
+ openWorldHint: true,
373
+ },
374
+ }, 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
+ };
396
+ });
397
+ server.registerTool('preflight_update_bundle', {
398
+ title: 'Update bundle',
399
+ description: 'Refresh/sync a bundle with latest repo changes. Use when: "update bundle", "refresh bundle", "sync bundle", "check for updates", "更新bundle", "同步仓库", "刷新索引". Set checkOnly=true to only check without applying.',
400
+ inputSchema: UpdateBundleInputSchema,
401
+ outputSchema: {
402
+ changed: z.boolean(),
403
+ checkOnly: z.boolean().optional(),
404
+ updateDetails: z.array(z.object({
405
+ repoId: z.string(),
406
+ currentSha: z.string().optional(),
407
+ remoteSha: z.string().optional(),
408
+ changed: z.boolean(),
409
+ })).optional(),
410
+ bundleId: z.string(),
411
+ createdAt: z.string().optional(),
412
+ updatedAt: z.string().optional(),
413
+ resources: z.object({
414
+ startHere: z.string(),
415
+ agents: z.string(),
416
+ overview: z.string(),
417
+ manifest: z.string(),
418
+ }).optional(),
419
+ repos: z.array(z.object({
420
+ kind: z.enum(['github', 'deepwiki']),
421
+ id: z.string(),
422
+ headSha: z.string().optional(),
423
+ notes: z.array(z.string()).optional(),
424
+ })).optional(),
425
+ libraries: z
426
+ .array(z.object({
427
+ kind: z.literal('context7'),
428
+ input: z.string(),
429
+ id: z.string().optional(),
430
+ fetchedAt: z.string(),
431
+ notes: z.array(z.string()).optional(),
432
+ files: z.array(z.string()).optional(),
433
+ }))
434
+ .optional(),
435
+ },
436
+ annotations: {
437
+ openWorldHint: true,
438
+ },
439
+ }, 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);
448
+ const out = {
449
+ bundleId: args.bundleId,
450
+ changed: hasUpdates,
451
+ checkOnly: true,
452
+ updateDetails: details,
453
+ };
454
+ return {
455
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
456
+ structuredContent: out,
457
+ };
458
+ }
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
+ };
475
+ });
476
+ server.registerTool('preflight_update_all_bundles', {
477
+ title: 'Update all bundles',
478
+ description: 'Batch update all bundles at once. Use when: "update all bundles", "refresh everything", "sync all", "批量更新", "全部刷新", "更新所有bundle".',
479
+ inputSchema: UpdateAllBundlesInputSchema,
480
+ outputSchema: {
481
+ total: z.number().int(),
482
+ ok: z.number().int(),
483
+ results: z.array(z.object({
484
+ bundleId: z.string(),
485
+ changed: z.boolean().optional(),
486
+ updatedAt: z.string().optional(),
487
+ error: z.string().optional(),
488
+ })),
489
+ },
490
+ annotations: {
491
+ openWorldHint: true,
492
+ },
493
+ }, async (args) => {
494
+ const effectiveDir = await getEffectiveStorageDir(cfg);
495
+ const ids = args.bundleIds && args.bundleIds.length > 0
496
+ ? args.bundleIds
497
+ : await listBundles(effectiveDir);
498
+ const results = [];
499
+ for (const bundleId of ids) {
500
+ try {
501
+ const exists = await bundleExists(effectiveDir, bundleId);
502
+ if (!exists) {
503
+ throw new Error(`Bundle not found: ${bundleId}`);
504
+ }
505
+ const { summary, changed } = await updateBundle(cfg, bundleId);
506
+ results.push({ bundleId, changed, updatedAt: summary.updatedAt });
507
+ }
508
+ catch (err) {
509
+ results.push({ bundleId, error: err instanceof Error ? err.message : String(err) });
510
+ }
511
+ }
512
+ const out = {
513
+ total: ids.length,
514
+ ok: results.filter((r) => !r.error).length,
515
+ results,
516
+ };
517
+ return {
518
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
519
+ structuredContent: out,
520
+ };
521
+ });
522
+ server.registerTool('preflight_search_by_tags', {
523
+ title: 'Search by tags',
524
+ description: 'Search across multiple bundles filtered by tags. Use when: "search in MCP bundles", "find in all agent repos", "search web-scraping tools", "在MCP项目中搜索", "搜索所有agent".',
525
+ inputSchema: SearchByTagsInputSchema,
526
+ outputSchema: {
527
+ query: z.string(),
528
+ tags: z.array(z.string()).optional(),
529
+ scope: z.enum(['docs', 'code', 'all']),
530
+ totalBundlesSearched: z.number(),
531
+ hits: z.array(z.object({
532
+ bundleId: z.string(),
533
+ bundleName: z.string().optional(),
534
+ kind: z.enum(['doc', 'code']),
535
+ repo: z.string(),
536
+ path: z.string(),
537
+ lineNo: z.number(),
538
+ snippet: z.string(),
539
+ uri: z.string(),
540
+ })),
541
+ },
542
+ annotations: {
543
+ readOnlyHint: true,
544
+ },
545
+ }, async (args) => {
546
+ const effectiveDir = await getEffectiveStorageDir(cfg);
547
+ 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 {
569
+ const paths = getBundlePathsForId(effectiveDir, bundleId);
570
+ 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
+ }
593
+ const out = {
594
+ query: args.query,
595
+ tags: args.tags,
596
+ scope: args.scope,
597
+ totalBundlesSearched: targetBundleIds.length,
598
+ hits: allHits.slice(0, args.limit),
599
+ };
600
+ return {
601
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
602
+ structuredContent: out,
603
+ };
604
+ });
605
+ server.registerTool('preflight_search_bundle', {
606
+ 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", "在仓库中查找", "搜代码", "搜文档".',
608
+ inputSchema: SearchBundleInputSchema,
609
+ outputSchema: {
610
+ bundleId: z.string(),
611
+ query: z.string(),
612
+ scope: z.enum(['docs', 'code', 'all']),
613
+ hits: z.array(z.object({
614
+ kind: z.enum(['doc', 'code']),
615
+ repo: z.string(),
616
+ path: z.string(),
617
+ lineNo: z.number(),
618
+ snippet: z.string(),
619
+ uri: z.string(),
620
+ })),
621
+ autoUpdated: z.boolean().optional().describe('True if bundle was auto-updated due to ensureFresh.'),
622
+ },
623
+ annotations: {
624
+ readOnlyHint: true,
625
+ },
626
+ }, 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;
643
+ }
644
+ else {
645
+ autoUpdated = false;
646
+ }
647
+ }
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
+ });
664
+ server.registerTool('preflight_verify_claim', {
665
+ 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", "验证说法", "找证据", "这个对吗", "有没有依据".',
667
+ inputSchema: VerifyClaimInputSchema,
668
+ outputSchema: {
669
+ bundleId: z.string(),
670
+ claim: z.string(),
671
+ scope: z.enum(['docs', 'code', 'all']),
672
+ found: z.boolean(),
673
+ confidence: z.number().describe('Confidence score 0-1'),
674
+ confidenceLabel: z.enum(['high', 'medium', 'low', 'none']),
675
+ summary: z.string().describe('Human-readable summary of verification'),
676
+ supporting: z.array(z.object({
677
+ kind: z.enum(['doc', 'code']),
678
+ repo: z.string(),
679
+ path: z.string(),
680
+ lineNo: z.number(),
681
+ snippet: z.string(),
682
+ uri: z.string(),
683
+ evidenceType: z.enum(['supporting', 'contradicting', 'related']),
684
+ relevanceScore: z.number(),
685
+ })).describe('Evidence supporting the claim'),
686
+ contradicting: z.array(z.object({
687
+ kind: z.enum(['doc', 'code']),
688
+ repo: z.string(),
689
+ path: z.string(),
690
+ lineNo: z.number(),
691
+ snippet: z.string(),
692
+ uri: z.string(),
693
+ evidenceType: z.enum(['supporting', 'contradicting', 'related']),
694
+ relevanceScore: z.number(),
695
+ })).describe('Evidence contradicting the claim'),
696
+ related: z.array(z.object({
697
+ kind: z.enum(['doc', 'code']),
698
+ repo: z.string(),
699
+ path: z.string(),
700
+ lineNo: z.number(),
701
+ snippet: z.string(),
702
+ uri: z.string(),
703
+ evidenceType: z.enum(['supporting', 'contradicting', 'related']),
704
+ relevanceScore: z.number(),
705
+ })).describe('Related but inconclusive evidence'),
706
+ autoUpdated: z.boolean().optional().describe('True if bundle was auto-updated due to ensureFresh.'),
707
+ },
708
+ annotations: {
709
+ readOnlyHint: true,
710
+ },
711
+ }, 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;
728
+ }
729
+ else {
730
+ autoUpdated = false;
731
+ }
732
+ }
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
+ });
758
+ // Provide backward-compatible parsing of the same URI via resources/read for clients that bypass templates.
759
+ // This is a safety net: if a client gives us a fully-specified URI, we can still serve it.
760
+ server.registerResource('bundle-file-compat', 'preflight://bundle-file', {
761
+ title: 'Bundle file (compat)',
762
+ description: 'Compatibility resource. Prefer preflight://bundle/{bundleId}/file/{encodedPath}.',
763
+ mimeType: 'text/plain',
764
+ }, async () => {
765
+ return {
766
+ contents: [
767
+ {
768
+ uri: 'preflight://bundle-file',
769
+ mimeType: 'text/plain',
770
+ text: 'Use preflight://bundle/{bundleId}/file/{encodedPath} to read bundle files.',
771
+ },
772
+ ],
773
+ };
774
+ });
775
+ // Connect via stdio.
776
+ const transport = new StdioServerTransport();
777
+ await server.connect(transport);
778
+ }