preflight-mcp 0.4.3 → 0.5.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/README.md +48 -3
- package/dist/evidence/dependencyGraph.js +120 -1
- package/dist/mcp/cursor.js +145 -0
- package/dist/mcp/envelope.js +61 -0
- package/dist/mcp/errorKinds.js +35 -0
- package/dist/mcp/pathRedaction.js +180 -0
- package/dist/mcp/responseBuilder.js +284 -0
- package/dist/mcp/responseMeta.js +37 -2
- package/dist/server.js +382 -19
- package/dist/tools/readFiles.js +224 -0
- package/dist/tools/searchAndRead.js +296 -0
- package/dist/trace/service.js +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,8 @@ Preflight: 🔗 Trace links:
|
|
|
51
51
|
- 📖 **Auto-generated guides** — `START_HERE.md`, `AGENTS.md`, `OVERVIEW.md`
|
|
52
52
|
- ☁️ **Cloud sync** — Multi-path mirror backup for redundancy
|
|
53
53
|
- 🧠 **EDDA (Evidence-Driven Deep Analysis)** — Auto-generate auditable claims with evidence
|
|
54
|
-
- ⚡ **
|
|
54
|
+
- ⚡ **22 MCP tools + 5 prompts** — Complete toolkit for code exploration
|
|
55
|
+
- 📄 **Cursor pagination** — Handle large result sets efficiently (RFC v2)
|
|
55
56
|
|
|
56
57
|
<details>
|
|
57
58
|
<summary><b>All Features (click to expand)</b></summary>
|
|
@@ -75,7 +76,7 @@ Preflight: 🔗 Trace links:
|
|
|
75
76
|
- [Demo](#demo)
|
|
76
77
|
- [Core Features](#core-features)
|
|
77
78
|
- [Quick Start](#quick-start)
|
|
78
|
-
- [Tools](#tools-
|
|
79
|
+
- [Tools](#tools-22-total)
|
|
79
80
|
- [Prompts](#prompts-5-total)
|
|
80
81
|
- [Environment Variables](#environment-variables)
|
|
81
82
|
- [Contributing](#contributing)
|
|
@@ -160,10 +161,11 @@ Run end-to-end smoke test:
|
|
|
160
161
|
npm run smoke
|
|
161
162
|
```
|
|
162
163
|
|
|
163
|
-
## Tools (
|
|
164
|
+
## Tools (22 total)
|
|
164
165
|
|
|
165
166
|
### `preflight_list_bundles`
|
|
166
167
|
List bundle IDs in storage.
|
|
168
|
+
- **Cursor pagination** (v0.5.0): Use `cursor` parameter for large bundle lists
|
|
167
169
|
- Triggers: "show bundles", "查看bundle", "有哪些bundle"
|
|
168
170
|
|
|
169
171
|
### `preflight_create_bundle`
|
|
@@ -236,10 +238,15 @@ Important: **this tool is strictly read-only**.
|
|
|
236
238
|
- `fileTypeFilters`: Filter by extension (e.g., `[".py", ".ts"]`)
|
|
237
239
|
- `includeScore`: Include BM25 relevance score in results
|
|
238
240
|
|
|
241
|
+
**Cursor pagination** (v0.5.0):
|
|
242
|
+
- `cursor`: Pagination cursor from previous call for fetching next page
|
|
243
|
+
- Response includes `truncation.nextCursor` when more results available
|
|
244
|
+
|
|
239
245
|
**Deprecated parameters**: `ensureFresh`, `autoRepairIndex`, `maxAgeHours` are deprecated and will return warnings instead of errors.
|
|
240
246
|
|
|
241
247
|
### `preflight_search_by_tags`
|
|
242
248
|
Search across multiple bundles filtered by tags (line-based SQLite FTS5).
|
|
249
|
+
- **Cursor pagination** (v0.5.0): Use `cursor` parameter for large result sets
|
|
243
250
|
- Triggers: "search in MCP bundles", "在MCP项目中搜索", "搜索所有agent"
|
|
244
251
|
|
|
245
252
|
Notes:
|
|
@@ -250,6 +257,7 @@ Optional parameters:
|
|
|
250
257
|
- `tags`: Filter bundles by tags (e.g., `["mcp", "agents"]`)
|
|
251
258
|
- `scope`: Search scope (`docs`, `code`, or `all`)
|
|
252
259
|
- `limit`: Max total hits across all bundles
|
|
260
|
+
- `cursor`: Pagination cursor for fetching next page
|
|
253
261
|
|
|
254
262
|
### `preflight_evidence_dependency_graph`
|
|
255
263
|
Generate an evidence-based dependency graph. Two modes:
|
|
@@ -280,6 +288,7 @@ Create or update traceability links (code↔test, code↔doc, file↔requirement
|
|
|
280
288
|
### `preflight_trace_query`
|
|
281
289
|
Query traceability links (code↔test, code↔doc, commit↔ticket).
|
|
282
290
|
- **Proactive use**: LLM automatically queries trace links when analyzing specific files
|
|
291
|
+
- **Cursor pagination** (v0.5.0): Use `cursor` parameter for large result sets
|
|
283
292
|
- Returns `reason` and `nextSteps` when no edges found (helps LLM decide next action)
|
|
284
293
|
- Fast when `bundleId` is provided; can scan across bundles when omitted.
|
|
285
294
|
|
|
@@ -327,6 +336,37 @@ Parameters:
|
|
|
327
336
|
- `verifyFileExists`: Check evidence files exist (default: true)
|
|
328
337
|
- `strictMode`: Treat warnings as errors (default: false)
|
|
329
338
|
|
|
339
|
+
### `preflight_read_files` *(NEW v0.5.0)*
|
|
340
|
+
Batch read multiple files from a bundle in a single call.
|
|
341
|
+
- Reduces round-trips for evidence gathering
|
|
342
|
+
- **RFC v2 unified envelope**: Returns `ok`, `meta`, `data`, `evidence[]`
|
|
343
|
+
- Triggers: "read these files", "get content of", "批量读取"
|
|
344
|
+
|
|
345
|
+
Parameters:
|
|
346
|
+
- `bundleId`: Bundle ID
|
|
347
|
+
- `files[]`: Array of `{path, ranges?, withLineNumbers?}`
|
|
348
|
+
- `format`: `"json"` (default) or `"text"`
|
|
349
|
+
|
|
350
|
+
### `preflight_search_and_read` *(NEW v0.5.0)*
|
|
351
|
+
Search + excerpt in one call - finds relevant code and returns context.
|
|
352
|
+
- Combines search with automatic context extraction
|
|
353
|
+
- **RFC v2 unified envelope**: Returns `ok`, `meta`, `data`, `evidence[]`
|
|
354
|
+
- Triggers: "search and show code", "find and read", "搜索并读取"
|
|
355
|
+
|
|
356
|
+
Parameters:
|
|
357
|
+
- `bundleId`: Bundle ID
|
|
358
|
+
- `query`: Search query
|
|
359
|
+
- `contextLines`: Lines of context around matches (default: 5)
|
|
360
|
+
- `maxFiles`: Max files to read (default: 5)
|
|
361
|
+
- `format`: `"json"` (default) or `"text"`
|
|
362
|
+
|
|
363
|
+
### `preflight_get_dependency_graph` *(Simplified wrapper)*
|
|
364
|
+
Simplified dependency graph query.
|
|
365
|
+
- `scope: "global"` (default): Project-wide graph
|
|
366
|
+
- `scope: "target"` with `targetFile`: Single file dependencies
|
|
367
|
+
- `format: "summary"` (default): Aggregated view
|
|
368
|
+
- `format: "full"`: Raw graph data
|
|
369
|
+
|
|
330
370
|
### `preflight_cleanup_orphans`
|
|
331
371
|
Remove incomplete or corrupted bundles (bundles without valid manifest.json).
|
|
332
372
|
- Triggers: "clean up broken bundles", "remove orphans", "清理孤儿bundle"
|
|
@@ -393,6 +433,11 @@ Common kinds:
|
|
|
393
433
|
- `invalid_path` (unsafe path traversal attempt)
|
|
394
434
|
- `permission_denied`
|
|
395
435
|
- `index_missing_or_corrupt`
|
|
436
|
+
- `cursor_invalid` *(v0.5.0)*
|
|
437
|
+
- `cursor_expired` *(v0.5.0)*
|
|
438
|
+
- `rate_limited` *(v0.5.0)*
|
|
439
|
+
- `timeout` *(v0.5.0)*
|
|
440
|
+
- `pagination_required` *(v0.5.0)*
|
|
396
441
|
- `unknown`
|
|
397
442
|
|
|
398
443
|
This is designed so UIs/agents can reliably decide whether to:
|
|
@@ -66,6 +66,119 @@ function clampSnippet(s, maxLen) {
|
|
|
66
66
|
function normalizeExt(p) {
|
|
67
67
|
return path.extname(p).toLowerCase();
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Generate a Mermaid flowchart from dependency graph edges.
|
|
71
|
+
* Limited to top N nodes to keep output readable.
|
|
72
|
+
*/
|
|
73
|
+
function generateMermaidDiagram(edges, maxNodes = 20) {
|
|
74
|
+
// Count imports per file (both as importer and imported)
|
|
75
|
+
const importerCounts = new Map();
|
|
76
|
+
const importedCounts = new Map();
|
|
77
|
+
for (const e of edges) {
|
|
78
|
+
if (e.type === 'imports' && e.from && e.to) {
|
|
79
|
+
importerCounts.set(e.from, (importerCounts.get(e.from) ?? 0) + 1);
|
|
80
|
+
importedCounts.set(e.to, (importedCounts.get(e.to) ?? 0) + 1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Get top nodes by total connections
|
|
84
|
+
const allNodes = new Set([...importerCounts.keys(), ...importedCounts.keys()]);
|
|
85
|
+
const nodeScores = Array.from(allNodes).map(n => ({
|
|
86
|
+
node: n,
|
|
87
|
+
score: (importerCounts.get(n) ?? 0) + (importedCounts.get(n) ?? 0),
|
|
88
|
+
})).sort((a, b) => b.score - a.score).slice(0, maxNodes);
|
|
89
|
+
const topNodes = new Set(nodeScores.map(n => n.node));
|
|
90
|
+
// Filter edges to only include top nodes
|
|
91
|
+
const filteredEdges = edges.filter(e => e.type === 'imports' && e.from && e.to &&
|
|
92
|
+
topNodes.has(e.from) && topNodes.has(e.to));
|
|
93
|
+
if (filteredEdges.length === 0) {
|
|
94
|
+
return '```mermaid\nflowchart LR\n A[No edges to display]\n```';
|
|
95
|
+
}
|
|
96
|
+
// Generate Mermaid syntax
|
|
97
|
+
const lines = ['```mermaid', 'flowchart LR'];
|
|
98
|
+
// Create node IDs (sanitize file names)
|
|
99
|
+
const nodeIds = new Map();
|
|
100
|
+
let idCounter = 0;
|
|
101
|
+
const getNodeId = (name) => {
|
|
102
|
+
if (!nodeIds.has(name)) {
|
|
103
|
+
nodeIds.set(name, `N${idCounter++}`);
|
|
104
|
+
}
|
|
105
|
+
return nodeIds.get(name);
|
|
106
|
+
};
|
|
107
|
+
// Add edges
|
|
108
|
+
const addedEdges = new Set();
|
|
109
|
+
for (const e of filteredEdges) {
|
|
110
|
+
const fromId = getNodeId(e.from);
|
|
111
|
+
const toId = getNodeId(e.to);
|
|
112
|
+
const edgeKey = `${fromId}->${toId}`;
|
|
113
|
+
if (!addedEdges.has(edgeKey)) {
|
|
114
|
+
addedEdges.add(edgeKey);
|
|
115
|
+
lines.push(` ${fromId} --> ${toId}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Add node labels
|
|
119
|
+
for (const [name, id] of nodeIds) {
|
|
120
|
+
// Extract just the filename for readability
|
|
121
|
+
const shortName = name.split('/').pop() ?? name;
|
|
122
|
+
const safeName = shortName.replace(/["]/g, "'").slice(0, 30);
|
|
123
|
+
lines.push(` ${id}["${safeName}"]`);
|
|
124
|
+
}
|
|
125
|
+
lines.push('```');
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Identify high-value modules from dependency graph.
|
|
130
|
+
*/
|
|
131
|
+
function identifyHighValueModules(edges, nodes) {
|
|
132
|
+
const modules = [];
|
|
133
|
+
// Count imports (as importer and as imported)
|
|
134
|
+
const importerCounts = new Map();
|
|
135
|
+
const importedCounts = new Map();
|
|
136
|
+
for (const e of edges) {
|
|
137
|
+
if (e.type === 'imports' && e.from && e.to) {
|
|
138
|
+
importerCounts.set(e.from, (importerCounts.get(e.from) ?? 0) + 1);
|
|
139
|
+
importedCounts.set(e.to, (importedCounts.get(e.to) ?? 0) + 1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// High coupling: files imported by many others (>10)
|
|
143
|
+
for (const [file, count] of importedCounts) {
|
|
144
|
+
if (count >= 10) {
|
|
145
|
+
modules.push({
|
|
146
|
+
file,
|
|
147
|
+
reason: 'high_coupling',
|
|
148
|
+
metric: count,
|
|
149
|
+
description: `Imported by ${count} files - core module, changes affect many dependents`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Hub modules: files that import many others (>15)
|
|
154
|
+
for (const [file, count] of importerCounts) {
|
|
155
|
+
if (count >= 15) {
|
|
156
|
+
modules.push({
|
|
157
|
+
file,
|
|
158
|
+
reason: 'hub',
|
|
159
|
+
metric: count,
|
|
160
|
+
description: `Imports ${count} modules - orchestrator/entry point, understand dependencies first`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Entry points: high importer count but low imported count
|
|
165
|
+
for (const [file, importCount] of importerCounts) {
|
|
166
|
+
const importedCount = importedCounts.get(file) ?? 0;
|
|
167
|
+
if (importCount >= 8 && importedCount <= 2) {
|
|
168
|
+
// Avoid duplicates
|
|
169
|
+
if (!modules.some(m => m.file === file && m.reason === 'entry_point')) {
|
|
170
|
+
modules.push({
|
|
171
|
+
file,
|
|
172
|
+
reason: 'entry_point',
|
|
173
|
+
metric: importCount,
|
|
174
|
+
description: `Likely entry point: imports ${importCount} modules but only imported by ${importedCount}`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Sort by metric descending, limit to top 10
|
|
180
|
+
return modules.sort((a, b) => b.metric - a.metric).slice(0, 10);
|
|
181
|
+
}
|
|
69
182
|
function parseRepoNormPath(bundleRelativePath) {
|
|
70
183
|
const p = bundleRelativePath.replaceAll('\\', '/').replace(/^\/+/, '');
|
|
71
184
|
const parts = p.split('/').filter(Boolean);
|
|
@@ -1196,6 +1309,10 @@ async function generateGlobalDependencyGraph(ctx) {
|
|
|
1196
1309
|
timeBudgetMs,
|
|
1197
1310
|
},
|
|
1198
1311
|
};
|
|
1312
|
+
// Generate high-value modules and Mermaid diagram
|
|
1313
|
+
const nodesArray = Array.from(nodes.values());
|
|
1314
|
+
const highValueModules = identifyHighValueModules(edges, nodesArray);
|
|
1315
|
+
const mermaid = edges.length > 0 ? generateMermaidDiagram(edges, 15) : undefined;
|
|
1199
1316
|
return {
|
|
1200
1317
|
meta: {
|
|
1201
1318
|
requestId,
|
|
@@ -1216,7 +1333,7 @@ async function generateGlobalDependencyGraph(ctx) {
|
|
|
1216
1333
|
},
|
|
1217
1334
|
},
|
|
1218
1335
|
facts: {
|
|
1219
|
-
nodes:
|
|
1336
|
+
nodes: nodesArray,
|
|
1220
1337
|
edges,
|
|
1221
1338
|
},
|
|
1222
1339
|
signals: {
|
|
@@ -1228,8 +1345,10 @@ async function generateGlobalDependencyGraph(ctx) {
|
|
|
1228
1345
|
importEdges,
|
|
1229
1346
|
},
|
|
1230
1347
|
warnings,
|
|
1348
|
+
highValueModules: highValueModules.length > 0 ? highValueModules : undefined,
|
|
1231
1349
|
},
|
|
1232
1350
|
coverageReport,
|
|
1351
|
+
mermaid,
|
|
1233
1352
|
};
|
|
1234
1353
|
}
|
|
1235
1354
|
/**
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC v2: Cursor encoding/decoding for stable pagination.
|
|
3
|
+
*
|
|
4
|
+
* Cursors are opaque, base64-encoded JSON objects that contain:
|
|
5
|
+
* - offset: The position in the result set
|
|
6
|
+
* - sortKey: The last seen sort key for stable ordering
|
|
7
|
+
* - tool: The tool that generated the cursor (for validation)
|
|
8
|
+
* - timestamp: When the cursor was created (for expiration)
|
|
9
|
+
*
|
|
10
|
+
* This enables LLMs to reliably paginate through large result sets.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Maximum cursor age in milliseconds (24 hours).
|
|
14
|
+
* Cursors older than this are considered expired.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_CURSOR_AGE_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
/**
|
|
18
|
+
* Encode cursor state to an opaque string.
|
|
19
|
+
*/
|
|
20
|
+
export function encodeCursor(state) {
|
|
21
|
+
const json = JSON.stringify(state);
|
|
22
|
+
// Use base64url encoding (URL-safe, no padding)
|
|
23
|
+
return Buffer.from(json, 'utf8').toString('base64url');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Decode cursor string to cursor state.
|
|
27
|
+
* Returns null if the cursor is invalid.
|
|
28
|
+
*/
|
|
29
|
+
export function decodeCursor(cursor) {
|
|
30
|
+
try {
|
|
31
|
+
const json = Buffer.from(cursor, 'base64url').toString('utf8');
|
|
32
|
+
const state = JSON.parse(json);
|
|
33
|
+
// Validate structure
|
|
34
|
+
if (typeof state !== 'object' ||
|
|
35
|
+
state === null ||
|
|
36
|
+
typeof state.offset !== 'number' ||
|
|
37
|
+
typeof state.tool !== 'string' ||
|
|
38
|
+
typeof state.timestamp !== 'number') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return state;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Validate a cursor for a specific tool.
|
|
49
|
+
* Checks structure, tool match, and expiration.
|
|
50
|
+
*/
|
|
51
|
+
export function validateCursor(cursor, expectedTool, options) {
|
|
52
|
+
const state = decodeCursor(cursor);
|
|
53
|
+
if (!state) {
|
|
54
|
+
return {
|
|
55
|
+
valid: false,
|
|
56
|
+
error: 'Invalid cursor format',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Check tool match
|
|
60
|
+
if (!options?.allowToolMismatch && state.tool !== expectedTool) {
|
|
61
|
+
return {
|
|
62
|
+
valid: false,
|
|
63
|
+
state,
|
|
64
|
+
error: `Cursor was created by ${state.tool}, not ${expectedTool}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Check expiration
|
|
68
|
+
const maxAge = options?.maxAgeMs ?? MAX_CURSOR_AGE_MS;
|
|
69
|
+
const age = Date.now() - state.timestamp;
|
|
70
|
+
if (age > maxAge) {
|
|
71
|
+
return {
|
|
72
|
+
valid: false,
|
|
73
|
+
state,
|
|
74
|
+
error: `Cursor expired (age: ${Math.round(age / 1000)}s, max: ${Math.round(maxAge / 1000)}s)`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
valid: true,
|
|
79
|
+
state,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Create a cursor for the next page of results.
|
|
84
|
+
*
|
|
85
|
+
* @param tool - The tool creating the cursor
|
|
86
|
+
* @param offset - Current offset (will be incremented by pageSize)
|
|
87
|
+
* @param pageSize - Number of items per page
|
|
88
|
+
* @param sortKey - Optional sort key for keyset pagination
|
|
89
|
+
* @param extra - Optional additional data
|
|
90
|
+
*/
|
|
91
|
+
export function createNextCursor(tool, offset, pageSize, sortKey, extra) {
|
|
92
|
+
const state = {
|
|
93
|
+
offset: offset + pageSize,
|
|
94
|
+
tool,
|
|
95
|
+
timestamp: Date.now(),
|
|
96
|
+
};
|
|
97
|
+
if (sortKey !== undefined)
|
|
98
|
+
state.sortKey = sortKey;
|
|
99
|
+
if (extra !== undefined)
|
|
100
|
+
state.extra = extra;
|
|
101
|
+
return encodeCursor(state);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Parse cursor or return default offset.
|
|
105
|
+
* Convenience function for tool handlers.
|
|
106
|
+
*
|
|
107
|
+
* @param cursor - Optional cursor string
|
|
108
|
+
* @param tool - Expected tool name
|
|
109
|
+
* @param defaultOffset - Default offset if no cursor (default: 0)
|
|
110
|
+
* @returns Offset to use and any error message
|
|
111
|
+
*/
|
|
112
|
+
export function parseCursorOrDefault(cursor, tool, defaultOffset = 0) {
|
|
113
|
+
if (!cursor) {
|
|
114
|
+
return { offset: defaultOffset };
|
|
115
|
+
}
|
|
116
|
+
const validation = validateCursor(cursor, tool);
|
|
117
|
+
if (!validation.valid) {
|
|
118
|
+
return { offset: defaultOffset, error: validation.error };
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
offset: validation.state.offset,
|
|
122
|
+
sortKey: validation.state.sortKey,
|
|
123
|
+
extra: validation.state.extra,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Helper to determine if pagination should continue.
|
|
128
|
+
*
|
|
129
|
+
* @param returnedCount - Number of items returned in this page
|
|
130
|
+
* @param limit - Requested limit
|
|
131
|
+
* @param totalCount - Optional total count (if known)
|
|
132
|
+
* @param currentOffset - Current offset in result set
|
|
133
|
+
*/
|
|
134
|
+
export function shouldPaginate(returnedCount, limit, totalCount, currentOffset = 0) {
|
|
135
|
+
// If we got fewer items than requested, we're at the end
|
|
136
|
+
if (returnedCount < limit) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
// If we know the total and have fetched everything, no more pages
|
|
140
|
+
if (totalCount !== undefined && currentOffset + returnedCount >= totalCount) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
// Otherwise, assume there might be more
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC v2: Unified Response Envelope for all Preflight MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* This module defines the standardized response structure that enables:
|
|
5
|
+
* - LLM-friendly JSON output with stable field names
|
|
6
|
+
* - Evidence-first design with traceable citations
|
|
7
|
+
* - Pagination/truncation support with cursor-based continuation
|
|
8
|
+
* - Structured error handling with recovery hints
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Schema version for response envelope.
|
|
12
|
+
* Increment when making breaking changes to envelope structure.
|
|
13
|
+
*/
|
|
14
|
+
export const SCHEMA_VERSION = '2.0';
|
|
15
|
+
/**
|
|
16
|
+
* Type guard to check if response is successful.
|
|
17
|
+
*/
|
|
18
|
+
export function isSuccessResponse(response) {
|
|
19
|
+
return response.ok === true && response.data !== undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Type guard to check if response is an error.
|
|
23
|
+
*/
|
|
24
|
+
export function isErrorResponse(response) {
|
|
25
|
+
return response.ok === false && response.error !== undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Helper to create a source range from line numbers.
|
|
29
|
+
*/
|
|
30
|
+
export function createRange(startLine, endLine, startCol, endCol) {
|
|
31
|
+
const range = { startLine, endLine };
|
|
32
|
+
if (startCol !== undefined)
|
|
33
|
+
range.startCol = startCol;
|
|
34
|
+
if (endCol !== undefined)
|
|
35
|
+
range.endCol = endCol;
|
|
36
|
+
return range;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Helper to create an evidence pointer.
|
|
40
|
+
*/
|
|
41
|
+
export function createEvidencePointer(path, range, options) {
|
|
42
|
+
const pointer = { path, range };
|
|
43
|
+
if (options?.uri)
|
|
44
|
+
pointer.uri = options.uri;
|
|
45
|
+
if (options?.snippet)
|
|
46
|
+
pointer.snippet = options.snippet;
|
|
47
|
+
if (options?.snippetSha256)
|
|
48
|
+
pointer.snippetSha256 = options.snippetSha256;
|
|
49
|
+
return pointer;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Format evidence pointer as citation string.
|
|
53
|
+
* Format: "path:startLine-endLine" or "path:line" for single line
|
|
54
|
+
*/
|
|
55
|
+
export function formatEvidenceCitation(pointer) {
|
|
56
|
+
const { path, range } = pointer;
|
|
57
|
+
if (range.startLine === range.endLine) {
|
|
58
|
+
return `${path}:${range.startLine}`;
|
|
59
|
+
}
|
|
60
|
+
return `${path}:${range.startLine}-${range.endLine}`;
|
|
61
|
+
}
|
package/dist/mcp/errorKinds.js
CHANGED
|
@@ -72,6 +72,41 @@ const LLM_RECOVERY_HINTS = {
|
|
|
72
72
|
This parameter is deprecated. The tool is now strictly read-only.
|
|
73
73
|
- For updates: use preflight_update_bundle first, then retry
|
|
74
74
|
- For repairs: use preflight_repair_bundle first, then retry`,
|
|
75
|
+
// RFC v2 additions
|
|
76
|
+
cursor_invalid: `💡 Recovery steps:
|
|
77
|
+
1. The cursor format is invalid or corrupted
|
|
78
|
+
2. Start fresh without a cursor to get the first page
|
|
79
|
+
3. Use the nextCursor from the previous response exactly as provided`,
|
|
80
|
+
cursor_expired: `💡 Recovery steps:
|
|
81
|
+
1. Cursors expire after 24 hours
|
|
82
|
+
2. Start fresh without a cursor to get the first page
|
|
83
|
+
3. Complete pagination within a reasonable time window`,
|
|
84
|
+
cursor_tool_mismatch: `💡 Recovery steps:
|
|
85
|
+
1. The cursor was created by a different tool
|
|
86
|
+
2. Use the cursor only with the same tool that created it
|
|
87
|
+
3. Start fresh without a cursor for this tool`,
|
|
88
|
+
rate_limited: `💡 Recovery steps:
|
|
89
|
+
1. You are making requests too quickly
|
|
90
|
+
2. Wait a few seconds before retrying
|
|
91
|
+
3. Consider batching multiple operations into single calls`,
|
|
92
|
+
timeout: `💡 Recovery steps:
|
|
93
|
+
1. The operation took too long to complete
|
|
94
|
+
2. Try with a smaller scope or limit
|
|
95
|
+
3. Use cursor pagination to process in smaller batches`,
|
|
96
|
+
pagination_required: `💡 Note:
|
|
97
|
+
The result set is large. Use cursor pagination:
|
|
98
|
+
1. Check the 'truncation' field in the response
|
|
99
|
+
2. Pass the 'nextCursor' value in subsequent calls
|
|
100
|
+
3. Continue until truncated=false`,
|
|
101
|
+
validation_error: `💡 Recovery steps:
|
|
102
|
+
1. Check that all required parameters are provided
|
|
103
|
+
2. Verify parameter types match the schema
|
|
104
|
+
3. Review the error message for specific field issues`,
|
|
105
|
+
partial_success: `💡 Note:
|
|
106
|
+
Some operations succeeded but others failed:
|
|
107
|
+
1. Check the 'warnings' array for details on failed items
|
|
108
|
+
2. Address individual issues and retry failed items
|
|
109
|
+
3. Successfully processed items are already applied`,
|
|
75
110
|
unknown: `💡 If this error persists:
|
|
76
111
|
1. Check the error message for specific details
|
|
77
112
|
2. Verify your input parameters match the tool's schema
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path redaction utilities for RFC v2.
|
|
3
|
+
*
|
|
4
|
+
* Provides functions to sanitize paths before returning them to LLM clients,
|
|
5
|
+
* removing sensitive information like usernames, home directories, etc.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_OPTIONS = {
|
|
8
|
+
redactUsername: true,
|
|
9
|
+
redactAbsolutePrefix: false,
|
|
10
|
+
customPatterns: [],
|
|
11
|
+
keepLastSegments: 0,
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Common home directory patterns across platforms.
|
|
15
|
+
*/
|
|
16
|
+
const HOME_DIR_PATTERNS = [
|
|
17
|
+
// Windows: C:\Users\username\...
|
|
18
|
+
/^[A-Za-z]:\\Users\\[^\\]+\\/i,
|
|
19
|
+
// Linux/macOS: /home/username/... or /Users/username/...
|
|
20
|
+
/^\/(?:home|Users)\/[^/]+\//,
|
|
21
|
+
// WSL: /mnt/c/Users/username/...
|
|
22
|
+
/^\/mnt\/[a-z]\/Users\/[^/]+\//i,
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* Redact sensitive information from a file path.
|
|
26
|
+
*
|
|
27
|
+
* @param path - The file path to redact
|
|
28
|
+
* @param options - Redaction options
|
|
29
|
+
* @returns The redacted path
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* redactPath('/Users/john/projects/myapp/src/main.ts')
|
|
33
|
+
* // Returns: '~/projects/myapp/src/main.ts'
|
|
34
|
+
*
|
|
35
|
+
* redactPath('C:\\Users\\john\\projects\\myapp\\src\\main.ts')
|
|
36
|
+
* // Returns: '~\\projects\\myapp\\src\\main.ts'
|
|
37
|
+
*/
|
|
38
|
+
export function redactPath(path, options = {}) {
|
|
39
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
40
|
+
let result = path;
|
|
41
|
+
// Apply username redaction
|
|
42
|
+
if (opts.redactUsername) {
|
|
43
|
+
for (const pattern of HOME_DIR_PATTERNS) {
|
|
44
|
+
result = result.replace(pattern, (match) => {
|
|
45
|
+
// Preserve the path separator style
|
|
46
|
+
return match.includes('\\') ? '~\\' : '~/';
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Apply absolute prefix redaction
|
|
51
|
+
if (opts.redactAbsolutePrefix) {
|
|
52
|
+
// Remove drive letters on Windows
|
|
53
|
+
result = result.replace(/^[A-Za-z]:/, '');
|
|
54
|
+
// Remove leading slash on Unix
|
|
55
|
+
result = result.replace(/^\//, '');
|
|
56
|
+
}
|
|
57
|
+
// Apply custom patterns
|
|
58
|
+
for (const { pattern, replacement } of opts.customPatterns ?? []) {
|
|
59
|
+
result = result.replace(pattern, replacement);
|
|
60
|
+
}
|
|
61
|
+
// Keep only last N segments
|
|
62
|
+
if (opts.keepLastSegments && opts.keepLastSegments > 0) {
|
|
63
|
+
const separator = result.includes('\\') ? '\\' : '/';
|
|
64
|
+
const segments = result.split(/[/\\]/);
|
|
65
|
+
if (segments.length > opts.keepLastSegments) {
|
|
66
|
+
result = '...' + separator + segments.slice(-opts.keepLastSegments).join(separator);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Redact paths in an object recursively.
|
|
73
|
+
* Looks for common path-like keys: path, file, filePath, dir, directory, uri, etc.
|
|
74
|
+
*
|
|
75
|
+
* @param obj - Object to redact paths in
|
|
76
|
+
* @param options - Redaction options
|
|
77
|
+
* @returns New object with redacted paths
|
|
78
|
+
*/
|
|
79
|
+
export function redactPathsInObject(obj, options = {}) {
|
|
80
|
+
if (obj === null || obj === undefined) {
|
|
81
|
+
return obj;
|
|
82
|
+
}
|
|
83
|
+
if (typeof obj === 'string') {
|
|
84
|
+
// Check if it looks like a path
|
|
85
|
+
if (isLikelyPath(obj)) {
|
|
86
|
+
return redactPath(obj, options);
|
|
87
|
+
}
|
|
88
|
+
return obj;
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(obj)) {
|
|
91
|
+
return obj.map(item => redactPathsInObject(item, options));
|
|
92
|
+
}
|
|
93
|
+
if (typeof obj === 'object') {
|
|
94
|
+
const result = {};
|
|
95
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
96
|
+
if (isPathKey(key) && typeof value === 'string') {
|
|
97
|
+
result[key] = redactPath(value, options);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
result[key] = redactPathsInObject(value, options);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
return obj;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Check if a key name typically contains a path value.
|
|
109
|
+
*/
|
|
110
|
+
function isPathKey(key) {
|
|
111
|
+
const pathKeys = [
|
|
112
|
+
'path',
|
|
113
|
+
'file',
|
|
114
|
+
'filePath',
|
|
115
|
+
'filepath',
|
|
116
|
+
'dir',
|
|
117
|
+
'directory',
|
|
118
|
+
'folder',
|
|
119
|
+
'rootDir',
|
|
120
|
+
'rootPath',
|
|
121
|
+
'absPath',
|
|
122
|
+
'absolutePath',
|
|
123
|
+
'relativePath',
|
|
124
|
+
'relPath',
|
|
125
|
+
'bundleRoot',
|
|
126
|
+
'storageDir',
|
|
127
|
+
'configPath',
|
|
128
|
+
];
|
|
129
|
+
const lowerKey = key.toLowerCase();
|
|
130
|
+
return pathKeys.some(pk => lowerKey === pk.toLowerCase() || lowerKey.endsWith(pk.toLowerCase()));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Check if a string looks like a file path.
|
|
134
|
+
*/
|
|
135
|
+
function isLikelyPath(str) {
|
|
136
|
+
// Check for common path patterns
|
|
137
|
+
return (
|
|
138
|
+
// Windows path: C:\...
|
|
139
|
+
/^[A-Za-z]:[/\\]/.test(str) ||
|
|
140
|
+
// Unix absolute path: /...
|
|
141
|
+
str.startsWith('/') ||
|
|
142
|
+
// Contains multiple path separators
|
|
143
|
+
(str.includes('/') && str.split('/').length > 2) ||
|
|
144
|
+
(str.includes('\\') && str.split('\\').length > 2));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Create a redaction function with preset options.
|
|
148
|
+
*
|
|
149
|
+
* @param options - Default options for the redactor
|
|
150
|
+
* @returns A redactPath function with the given options preset
|
|
151
|
+
*/
|
|
152
|
+
export function createRedactor(options) {
|
|
153
|
+
return (path) => redactPath(path, options);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Check if a path appears to contain sensitive information.
|
|
157
|
+
*
|
|
158
|
+
* @param path - Path to check
|
|
159
|
+
* @returns True if the path might contain sensitive info
|
|
160
|
+
*/
|
|
161
|
+
export function containsSensitiveInfo(path) {
|
|
162
|
+
// Check for home directory patterns
|
|
163
|
+
for (const pattern of HOME_DIR_PATTERNS) {
|
|
164
|
+
if (pattern.test(path)) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Check for other potentially sensitive patterns
|
|
169
|
+
const sensitivePatterns = [
|
|
170
|
+
/\.ssh\//i,
|
|
171
|
+
/\.gnupg\//i,
|
|
172
|
+
/\.aws\//i,
|
|
173
|
+
/\.azure\//i,
|
|
174
|
+
/credentials/i,
|
|
175
|
+
/secrets?/i,
|
|
176
|
+
/private/i,
|
|
177
|
+
/\.env$/i,
|
|
178
|
+
];
|
|
179
|
+
return sensitivePatterns.some(p => p.test(path));
|
|
180
|
+
}
|