cursor-guard 2.1.0 → 4.0.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 +69 -11
- package/README.zh-CN.md +351 -293
- package/ROADMAP.md +1040 -0
- package/SKILL.md +631 -557
- package/package.json +26 -6
- package/references/config-reference.md +215 -175
- package/references/config-reference.zh-CN.md +215 -175
- package/references/cursor-guard.example.json +6 -6
- package/references/cursor-guard.schema.json +30 -0
- package/references/lib/auto-backup.js +315 -530
- package/references/lib/core/anomaly.js +217 -0
- package/references/lib/core/backups.js +357 -0
- package/references/lib/core/core.test.js +1459 -0
- package/references/lib/core/dashboard.js +208 -0
- package/references/lib/core/doctor-fix.js +237 -0
- package/references/lib/core/doctor.js +248 -0
- package/references/lib/core/restore.js +360 -0
- package/references/lib/core/snapshot.js +198 -0
- package/references/lib/core/status.js +163 -0
- package/references/lib/guard-doctor.js +46 -238
- package/references/lib/utils.js +438 -371
- package/references/mcp/mcp.test.js +374 -0
- package/references/mcp/server.js +252 -0
- package/references/quickstart.zh-CN.md +364 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { execFileSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
let passed = 0;
|
|
10
|
+
let failed = 0;
|
|
11
|
+
let serverProcess = null;
|
|
12
|
+
let msgId = 0;
|
|
13
|
+
|
|
14
|
+
function log(color, sym, msg) {
|
|
15
|
+
const c = { green: 32, red: 31 }[color] || 0;
|
|
16
|
+
console.log(` \x1b[${c}m${sym}\x1b[0m ${msg}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createTempGitRepo() {
|
|
20
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-mcp-test-'));
|
|
21
|
+
execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
|
22
|
+
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
|
|
23
|
+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
|
|
24
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello world');
|
|
25
|
+
fs.mkdirSync(path.join(tmpDir, 'src'));
|
|
26
|
+
fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'console.log("app");');
|
|
27
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
28
|
+
execFileSync('git', ['commit', '-m', 'initial', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
29
|
+
return tmpDir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function startServer() {
|
|
33
|
+
const serverPath = path.join(__dirname, 'server.js');
|
|
34
|
+
serverProcess = spawn('node', [serverPath], {
|
|
35
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
36
|
+
});
|
|
37
|
+
serverProcess.stderr.on('data', () => {});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sendMessage(msg) {
|
|
41
|
+
serverProcess.stdin.write(JSON.stringify(msg) + '\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readResponse() {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
let buffer = '';
|
|
47
|
+
const timeout = setTimeout(() => {
|
|
48
|
+
serverProcess.stdout.removeListener('data', onData);
|
|
49
|
+
reject(new Error('timeout waiting for response'));
|
|
50
|
+
}, 15000);
|
|
51
|
+
|
|
52
|
+
function onData(chunk) {
|
|
53
|
+
buffer += chunk.toString();
|
|
54
|
+
const lines = buffer.split('\n');
|
|
55
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
56
|
+
const line = lines[i].trim();
|
|
57
|
+
if (!line) continue;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(line);
|
|
60
|
+
if (parsed.id !== undefined || parsed.result !== undefined) {
|
|
61
|
+
clearTimeout(timeout);
|
|
62
|
+
serverProcess.stdout.removeListener('data', onData);
|
|
63
|
+
resolve(parsed);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
} catch { /* not valid JSON yet, continue */ }
|
|
67
|
+
}
|
|
68
|
+
buffer = lines[lines.length - 1];
|
|
69
|
+
}
|
|
70
|
+
serverProcess.stdout.on('data', onData);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function rpc(method, params) {
|
|
75
|
+
const id = ++msgId;
|
|
76
|
+
sendMessage({ jsonrpc: '2.0', id, method, params });
|
|
77
|
+
return await readResponse();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function test(name, fn) {
|
|
81
|
+
try {
|
|
82
|
+
await fn();
|
|
83
|
+
passed++;
|
|
84
|
+
log('green', '✓', name);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
failed++;
|
|
87
|
+
log('red', '✗', name);
|
|
88
|
+
console.log(` ${e.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function run() {
|
|
93
|
+
const tmpDir = createTempGitRepo();
|
|
94
|
+
|
|
95
|
+
console.log('\nMCP Server:');
|
|
96
|
+
|
|
97
|
+
startServer();
|
|
98
|
+
|
|
99
|
+
// Initialize
|
|
100
|
+
const initResp = await rpc('initialize', {
|
|
101
|
+
protocolVersion: '2024-11-05',
|
|
102
|
+
capabilities: {},
|
|
103
|
+
clientInfo: { name: 'test-client', version: '1.0' },
|
|
104
|
+
});
|
|
105
|
+
sendMessage({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
106
|
+
|
|
107
|
+
await test('initialize returns server info', () => {
|
|
108
|
+
if (!initResp.result) throw new Error('no result');
|
|
109
|
+
if (!initResp.result.serverInfo) throw new Error('no serverInfo');
|
|
110
|
+
if (initResp.result.serverInfo.name !== 'cursor-guard') throw new Error(`wrong name: ${initResp.result.serverInfo.name}`);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// List tools
|
|
114
|
+
const toolsResp = await rpc('tools/list', {});
|
|
115
|
+
|
|
116
|
+
await test('lists 9 tools', () => {
|
|
117
|
+
const tools = toolsResp.result.tools;
|
|
118
|
+
if (!tools) throw new Error('no tools');
|
|
119
|
+
if (tools.length !== 9) throw new Error(`expected 9 tools, got ${tools.length}`);
|
|
120
|
+
const names = tools.map(t => t.name).sort();
|
|
121
|
+
const expected = ['alert_status', 'backup_status', 'dashboard', 'doctor', 'doctor_fix', 'list_backups', 'restore_file', 'restore_project', 'snapshot_now'].sort();
|
|
122
|
+
if (JSON.stringify(names) !== JSON.stringify(expected)) {
|
|
123
|
+
throw new Error(`tool names mismatch: ${JSON.stringify(names)}`);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Call doctor
|
|
128
|
+
const doctorResp = await rpc('tools/call', {
|
|
129
|
+
name: 'doctor',
|
|
130
|
+
arguments: { path: tmpDir },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await test('doctor returns structured checks', () => {
|
|
134
|
+
const content = doctorResp.result.content[0].text;
|
|
135
|
+
const data = JSON.parse(content);
|
|
136
|
+
if (!data.checks) throw new Error('no checks');
|
|
137
|
+
if (!data.summary) throw new Error('no summary');
|
|
138
|
+
if (typeof data.summary.pass !== 'number') throw new Error('summary.pass not a number');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Call snapshot_now
|
|
142
|
+
const snapResp = await rpc('tools/call', {
|
|
143
|
+
name: 'snapshot_now',
|
|
144
|
+
arguments: { path: tmpDir, strategy: 'git' },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('snapshot_now creates git snapshot', () => {
|
|
148
|
+
const content = snapResp.result.content[0].text;
|
|
149
|
+
const data = JSON.parse(content);
|
|
150
|
+
if (!data.git) throw new Error('no git result');
|
|
151
|
+
if (data.git.status !== 'created' && data.git.status !== 'skipped') {
|
|
152
|
+
throw new Error(`unexpected status: ${data.git.status}`);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Call list_backups
|
|
157
|
+
const listResp = await rpc('tools/call', {
|
|
158
|
+
name: 'list_backups',
|
|
159
|
+
arguments: { path: tmpDir },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await test('list_backups returns sources', () => {
|
|
163
|
+
const content = listResp.result.content[0].text;
|
|
164
|
+
const data = JSON.parse(content);
|
|
165
|
+
if (!Array.isArray(data.sources)) throw new Error('sources not an array');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Setup for restore tests
|
|
169
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
170
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
|
|
171
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
172
|
+
execFileSync('git', ['commit', '-m', 'change', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
173
|
+
|
|
174
|
+
// Call restore_project (preview)
|
|
175
|
+
const previewResp = await rpc('tools/call', {
|
|
176
|
+
name: 'restore_project',
|
|
177
|
+
arguments: { path: tmpDir, source: headHash, preview: true },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await test('restore_project returns preview', () => {
|
|
181
|
+
const content = previewResp.result.content[0].text;
|
|
182
|
+
const data = JSON.parse(content);
|
|
183
|
+
if (data.status !== 'ok') throw new Error(`status: ${data.status}`);
|
|
184
|
+
if (!Array.isArray(data.files)) throw new Error('files not an array');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Call restore_project (execute mode)
|
|
188
|
+
// Re-stage the change so we have something to restore
|
|
189
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed-again');
|
|
190
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
191
|
+
execFileSync('git', ['commit', '-m', 'change again', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
192
|
+
|
|
193
|
+
const execResp = await rpc('tools/call', {
|
|
194
|
+
name: 'restore_project',
|
|
195
|
+
arguments: { path: tmpDir, source: headHash, preview: false, preserve_current: true },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await test('restore_project executes restore', () => {
|
|
199
|
+
const content = execResp.result.content[0].text;
|
|
200
|
+
const data = JSON.parse(content);
|
|
201
|
+
if (data.status !== 'restored') throw new Error(`status: ${data.status}, error: ${data.error}`);
|
|
202
|
+
if (typeof data.filesRestored !== 'number') throw new Error('filesRestored missing');
|
|
203
|
+
const actual = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
|
|
204
|
+
if (actual !== 'hello world') throw new Error(`content mismatch: "${actual}"`);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Call restore_file
|
|
208
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'overwritten');
|
|
209
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
210
|
+
execFileSync('git', ['commit', '-m', 'overwrite', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
211
|
+
|
|
212
|
+
const restoreResp = await rpc('tools/call', {
|
|
213
|
+
name: 'restore_file',
|
|
214
|
+
arguments: { path: tmpDir, file: 'hello.txt', source: headHash, preserve_current: false },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await test('restore_file restores successfully', () => {
|
|
218
|
+
const content = restoreResp.result.content[0].text;
|
|
219
|
+
const data = JSON.parse(content);
|
|
220
|
+
if (data.status !== 'restored') throw new Error(`status: ${data.status}, error: ${data.error}`);
|
|
221
|
+
const actual = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
|
|
222
|
+
if (actual !== 'hello world') throw new Error(`content mismatch: "${actual}"`);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Call backup_status
|
|
226
|
+
const statusResp = await rpc('tools/call', {
|
|
227
|
+
name: 'backup_status',
|
|
228
|
+
arguments: { path: tmpDir },
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await test('backup_status returns structured status', () => {
|
|
232
|
+
const content = statusResp.result.content[0].text;
|
|
233
|
+
const data = JSON.parse(content);
|
|
234
|
+
if (typeof data.watcher !== 'object') throw new Error('no watcher');
|
|
235
|
+
if (typeof data.config !== 'object') throw new Error('no config');
|
|
236
|
+
if (typeof data.lastBackup !== 'object') throw new Error('no lastBackup');
|
|
237
|
+
if (typeof data.refs !== 'object') throw new Error('no refs');
|
|
238
|
+
if (typeof data.disk !== 'object') throw new Error('no disk');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Call doctor_fix (dry-run)
|
|
242
|
+
const fixDryResp = await rpc('tools/call', {
|
|
243
|
+
name: 'doctor_fix',
|
|
244
|
+
arguments: { path: tmpDir, dry_run: true },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await test('doctor_fix dry-run returns actions without fixing', () => {
|
|
248
|
+
const content = fixDryResp.result.content[0].text;
|
|
249
|
+
const data = JSON.parse(content);
|
|
250
|
+
if (!Array.isArray(data.actions)) throw new Error('actions not an array');
|
|
251
|
+
if (data.totalFixed !== 0) throw new Error(`dry-run should not fix, but fixed ${data.totalFixed}`);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Call doctor_fix (apply)
|
|
255
|
+
const fixResp = await rpc('tools/call', {
|
|
256
|
+
name: 'doctor_fix',
|
|
257
|
+
arguments: { path: tmpDir },
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await test('doctor_fix returns structured result', () => {
|
|
261
|
+
const content = fixResp.result.content[0].text;
|
|
262
|
+
const data = JSON.parse(content);
|
|
263
|
+
if (!Array.isArray(data.actions)) throw new Error('actions not an array');
|
|
264
|
+
if (typeof data.totalFixed !== 'number') throw new Error('totalFixed missing');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Call dashboard
|
|
268
|
+
const dashResp = await rpc('tools/call', {
|
|
269
|
+
name: 'dashboard',
|
|
270
|
+
arguments: { path: tmpDir },
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await test('dashboard returns comprehensive health data', () => {
|
|
274
|
+
const content = dashResp.result.content[0].text;
|
|
275
|
+
const data = JSON.parse(content);
|
|
276
|
+
if (typeof data.strategy !== 'string') throw new Error('no strategy');
|
|
277
|
+
if (typeof data.counts !== 'object') throw new Error('no counts');
|
|
278
|
+
if (typeof data.diskUsage !== 'object') throw new Error('no diskUsage');
|
|
279
|
+
if (typeof data.protectionScope !== 'object') throw new Error('no protectionScope');
|
|
280
|
+
if (typeof data.health !== 'object') throw new Error('no health');
|
|
281
|
+
if (!['healthy', 'warning', 'critical'].includes(data.health.status)) throw new Error(`invalid health: ${data.health.status}`);
|
|
282
|
+
if (typeof data.alerts !== 'object') throw new Error('no alerts');
|
|
283
|
+
if (typeof data.watcher !== 'object') throw new Error('no watcher');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Call alert_status
|
|
287
|
+
const alertResp = await rpc('tools/call', {
|
|
288
|
+
name: 'alert_status',
|
|
289
|
+
arguments: { path: tmpDir },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await test('alert_status returns alert info', () => {
|
|
293
|
+
const content = alertResp.result.content[0].text;
|
|
294
|
+
const data = JSON.parse(content);
|
|
295
|
+
if (typeof data.active !== 'boolean') throw new Error('no active field');
|
|
296
|
+
if (data.active && !data.alert) throw new Error('active but no alert');
|
|
297
|
+
if (!data.active && !data.message) throw new Error('inactive but no message');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ── injectAlert verification ──────────────────────────────────
|
|
301
|
+
// Write an active alert file so all tools should return _activeAlert
|
|
302
|
+
const gitDir = path.join(tmpDir, '.git');
|
|
303
|
+
const alertFile = path.join(gitDir, 'cursor-guard-alert.json');
|
|
304
|
+
const fakeAlert = {
|
|
305
|
+
type: 'high_change_velocity',
|
|
306
|
+
detectedAt: Date.now(),
|
|
307
|
+
timestamp: new Date().toISOString(),
|
|
308
|
+
fileCount: 30,
|
|
309
|
+
windowSeconds: 10,
|
|
310
|
+
threshold: 20,
|
|
311
|
+
expiresAt: new Date(Date.now() + 300000).toISOString(),
|
|
312
|
+
recommendation: 'Test alert for injection verification',
|
|
313
|
+
};
|
|
314
|
+
fs.writeFileSync(alertFile, JSON.stringify(fakeAlert, null, 2));
|
|
315
|
+
|
|
316
|
+
const toolsWithAlert = ['doctor', 'list_backups', 'backup_status', 'dashboard', 'doctor_fix', 'snapshot_now'];
|
|
317
|
+
for (const toolName of toolsWithAlert) {
|
|
318
|
+
const resp = await rpc('tools/call', {
|
|
319
|
+
name: toolName,
|
|
320
|
+
arguments: { path: tmpDir, ...(toolName === 'doctor_fix' ? { dry_run: true } : {}), ...(toolName === 'snapshot_now' ? { strategy: 'git' } : {}) },
|
|
321
|
+
});
|
|
322
|
+
await test(`${toolName} response contains _activeAlert when alert exists`, () => {
|
|
323
|
+
const content = resp.result.content[0].text;
|
|
324
|
+
const data = JSON.parse(content);
|
|
325
|
+
if (!data._activeAlert) throw new Error(`_activeAlert missing from ${toolName} response`);
|
|
326
|
+
if (data._activeAlert.type !== 'high_change_velocity') throw new Error(`wrong alert type: ${data._activeAlert.type}`);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Verify restore_file also injects
|
|
331
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'pre-alert-test');
|
|
332
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
333
|
+
execFileSync('git', ['commit', '-m', 'for alert test', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
334
|
+
const alertHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
335
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'post-alert-test');
|
|
336
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
337
|
+
execFileSync('git', ['commit', '-m', 'for alert test 2', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
338
|
+
|
|
339
|
+
const restoreAlertResp = await rpc('tools/call', {
|
|
340
|
+
name: 'restore_file',
|
|
341
|
+
arguments: { path: tmpDir, file: 'hello.txt', source: alertHead, preserve_current: false },
|
|
342
|
+
});
|
|
343
|
+
await test('restore_file response contains _activeAlert', () => {
|
|
344
|
+
const content = restoreAlertResp.result.content[0].text;
|
|
345
|
+
const data = JSON.parse(content);
|
|
346
|
+
if (!data._activeAlert) throw new Error('_activeAlert missing from restore_file');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const rpResp = await rpc('tools/call', {
|
|
350
|
+
name: 'restore_project',
|
|
351
|
+
arguments: { path: tmpDir, source: alertHead, preview: true },
|
|
352
|
+
});
|
|
353
|
+
await test('restore_project response contains _activeAlert', () => {
|
|
354
|
+
const content = rpResp.result.content[0].text;
|
|
355
|
+
const data = JSON.parse(content);
|
|
356
|
+
if (!data._activeAlert) throw new Error('_activeAlert missing from restore_project');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Clean up alert file
|
|
360
|
+
try { fs.unlinkSync(alertFile); } catch { /* ignore */ }
|
|
361
|
+
|
|
362
|
+
// Cleanup
|
|
363
|
+
serverProcess.kill();
|
|
364
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
365
|
+
|
|
366
|
+
console.log(`\n${passed + failed} tests: \x1b[32m${passed} passed\x1b[0m` + (failed ? `, \x1b[31m${failed} failed\x1b[0m` : ''));
|
|
367
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
run().catch(err => {
|
|
371
|
+
console.error('Test runner error:', err);
|
|
372
|
+
if (serverProcess) serverProcess.kill();
|
|
373
|
+
process.exit(1);
|
|
374
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
6
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
7
|
+
const { z } = require('zod');
|
|
8
|
+
|
|
9
|
+
const { runDiagnostics } = require('../lib/core/doctor');
|
|
10
|
+
const { createGitSnapshot, createShadowCopy } = require('../lib/core/snapshot');
|
|
11
|
+
const { listBackups } = require('../lib/core/backups');
|
|
12
|
+
const { restoreFile, previewProjectRestore, executeProjectRestore } = require('../lib/core/restore');
|
|
13
|
+
const { runFixes } = require('../lib/core/doctor-fix');
|
|
14
|
+
const { getBackupStatus } = require('../lib/core/status');
|
|
15
|
+
const { getDashboard } = require('../lib/core/dashboard');
|
|
16
|
+
const { loadActiveAlert } = require('../lib/core/anomaly');
|
|
17
|
+
|
|
18
|
+
const pkg = require('../../package.json');
|
|
19
|
+
|
|
20
|
+
// ── Alert injection helper ──────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function injectAlert(projectPath, result) {
|
|
23
|
+
const alert = loadActiveAlert(projectPath);
|
|
24
|
+
if (alert) {
|
|
25
|
+
result._activeAlert = {
|
|
26
|
+
type: alert.type,
|
|
27
|
+
message: alert.recommendation || `${alert.fileCount} files changed in ${alert.windowSeconds}s`,
|
|
28
|
+
timestamp: alert.timestamp,
|
|
29
|
+
expiresAt: alert.expiresAt,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Server ──────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const server = new McpServer({
|
|
38
|
+
name: 'cursor-guard',
|
|
39
|
+
version: pkg.version,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── Tool 1: doctor ──────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
server.tool(
|
|
45
|
+
'doctor',
|
|
46
|
+
'Run health checks on a project: environment, config, Git, backup refs, shadow copies, disk space. Read-only, safe to call anytime.',
|
|
47
|
+
{
|
|
48
|
+
path: z.string().describe('Absolute path to the project directory'),
|
|
49
|
+
},
|
|
50
|
+
async ({ path: projectPath }) => {
|
|
51
|
+
const resolved = path.resolve(projectPath);
|
|
52
|
+
const result = injectAlert(resolved, runDiagnostics(resolved));
|
|
53
|
+
return {
|
|
54
|
+
content: [{
|
|
55
|
+
type: 'text',
|
|
56
|
+
text: JSON.stringify(result, null, 2),
|
|
57
|
+
}],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// ── Tool 2: list_backups ────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
server.tool(
|
|
65
|
+
'list_backups',
|
|
66
|
+
'List available backup/restore points from all sources (git refs, shadow copies). Read-only. Use before restore to find candidate versions.',
|
|
67
|
+
{
|
|
68
|
+
path: z.string().describe('Absolute path to the project directory'),
|
|
69
|
+
file: z.string().optional().describe('Filter to a specific file (relative path)'),
|
|
70
|
+
before: z.string().optional().describe('Only show backups before this time (e.g. "10 minutes ago", "2026-03-21T14:00:00")'),
|
|
71
|
+
limit: z.number().optional().describe('Max results per source (default 20)'),
|
|
72
|
+
},
|
|
73
|
+
async ({ path: projectPath, file, before, limit }) => {
|
|
74
|
+
const resolved = path.resolve(projectPath);
|
|
75
|
+
const result = injectAlert(resolved, listBackups(resolved, { file, before, limit }));
|
|
76
|
+
return {
|
|
77
|
+
content: [{
|
|
78
|
+
type: 'text',
|
|
79
|
+
text: JSON.stringify(result, null, 2),
|
|
80
|
+
}],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// ── Tool 3: snapshot_now ────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
server.tool(
|
|
88
|
+
'snapshot_now',
|
|
89
|
+
'Create an immediate backup snapshot of the current project state. Use before risky operations to preserve a restore point.',
|
|
90
|
+
{
|
|
91
|
+
path: z.string().describe('Absolute path to the project directory'),
|
|
92
|
+
strategy: z.enum(['git', 'shadow', 'both']).optional().describe('Backup strategy (default: from config, or "git")'),
|
|
93
|
+
message: z.string().optional().describe('Custom commit message for git snapshot'),
|
|
94
|
+
},
|
|
95
|
+
async ({ path: projectPath, strategy, message }) => {
|
|
96
|
+
const resolved = path.resolve(projectPath);
|
|
97
|
+
const { loadConfig } = require('../lib/utils');
|
|
98
|
+
const { cfg } = loadConfig(resolved);
|
|
99
|
+
|
|
100
|
+
const effectiveStrategy = strategy || cfg.backup_strategy || 'git';
|
|
101
|
+
const results = {};
|
|
102
|
+
|
|
103
|
+
if (effectiveStrategy === 'git' || effectiveStrategy === 'both') {
|
|
104
|
+
results.git = createGitSnapshot(resolved, cfg, {
|
|
105
|
+
branchRef: 'refs/guard/snapshot',
|
|
106
|
+
message: message || `guard: manual snapshot ${new Date().toISOString()}`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (effectiveStrategy === 'shadow' || effectiveStrategy === 'both') {
|
|
111
|
+
results.shadow = createShadowCopy(resolved, cfg);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
injectAlert(resolved, results);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
content: [{
|
|
118
|
+
type: 'text',
|
|
119
|
+
text: JSON.stringify(results, null, 2),
|
|
120
|
+
}],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// ── Tool 4: restore_file ────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
server.tool(
|
|
128
|
+
'restore_file',
|
|
129
|
+
'Restore a single file from a backup source (git commit/ref or shadow copy timestamp). By default, preserves the current version in a pre-restore snapshot before restoring.',
|
|
130
|
+
{
|
|
131
|
+
path: z.string().describe('Absolute path to the project directory'),
|
|
132
|
+
file: z.string().describe('Relative path to the file to restore'),
|
|
133
|
+
source: z.string().describe('Backup source: git commit hash, ref name, or shadow copy timestamp (e.g. "20260321_143205")'),
|
|
134
|
+
preserve_current: z.boolean().optional().describe('Create pre-restore snapshot before restoring (default true)'),
|
|
135
|
+
},
|
|
136
|
+
async ({ path: projectPath, file, source, preserve_current }) => {
|
|
137
|
+
const resolved = path.resolve(projectPath);
|
|
138
|
+
const result = injectAlert(resolved, restoreFile(resolved, file, source, {
|
|
139
|
+
preserveCurrent: preserve_current,
|
|
140
|
+
}));
|
|
141
|
+
return {
|
|
142
|
+
content: [{
|
|
143
|
+
type: 'text',
|
|
144
|
+
text: JSON.stringify(result, null, 2),
|
|
145
|
+
}],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// ── Tool 5: restore_project ─────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
server.tool(
|
|
153
|
+
'restore_project',
|
|
154
|
+
'Preview or execute a full project restore to a given backup point. In preview mode (default), shows affected files (including untracked) without changes. In execute mode, creates a pre-restore snapshot then restores all tracked files and cleans untracked files.',
|
|
155
|
+
{
|
|
156
|
+
path: z.string().describe('Absolute path to the project directory'),
|
|
157
|
+
source: z.string().describe('Backup source: git commit hash or ref name'),
|
|
158
|
+
preview: z.boolean().optional().describe('If true (default), only show what would change. If false, execute the restore.'),
|
|
159
|
+
preserve_current: z.boolean().optional().describe('Create pre-restore snapshot before executing (default true, only used when preview=false)'),
|
|
160
|
+
clean_untracked: z.boolean().optional().describe('Remove untracked non-ignored files after restore (default true, only used when preview=false)'),
|
|
161
|
+
},
|
|
162
|
+
async ({ path: projectPath, source, preview, preserve_current, clean_untracked }) => {
|
|
163
|
+
const resolved = path.resolve(projectPath);
|
|
164
|
+
|
|
165
|
+
if (preview !== false) {
|
|
166
|
+
const result = injectAlert(resolved, previewProjectRestore(resolved, source));
|
|
167
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const result = injectAlert(resolved, executeProjectRestore(resolved, source, {
|
|
171
|
+
preserveCurrent: preserve_current,
|
|
172
|
+
cleanUntracked: clean_untracked,
|
|
173
|
+
}));
|
|
174
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
175
|
+
}
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// ── Tool 6: doctor_fix ──────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
server.tool(
|
|
181
|
+
'doctor_fix',
|
|
182
|
+
'Auto-fix common configuration and environment issues: create missing config, init git repo, add .cursor-guard-backup/ to .gitignore, remove stale lock files, fix strategy mismatch. Each fix is idempotent. Use dry_run=true to preview without changes.',
|
|
183
|
+
{
|
|
184
|
+
path: z.string().describe('Absolute path to the project directory'),
|
|
185
|
+
dry_run: z.boolean().optional().describe('If true, report what would be fixed without modifying anything (default false)'),
|
|
186
|
+
},
|
|
187
|
+
async ({ path: projectPath, dry_run }) => {
|
|
188
|
+
const resolved = path.resolve(projectPath);
|
|
189
|
+
const result = injectAlert(resolved, runFixes(resolved, { dryRun: !!dry_run }));
|
|
190
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// ── Tool 7: backup_status ───────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
server.tool(
|
|
197
|
+
'backup_status',
|
|
198
|
+
'Get comprehensive backup system status: watcher running/stale, last backup time per strategy, configured strategy and retention, guard ref counts, disk space. Read-only, safe to call anytime.',
|
|
199
|
+
{
|
|
200
|
+
path: z.string().describe('Absolute path to the project directory'),
|
|
201
|
+
},
|
|
202
|
+
async ({ path: projectPath }) => {
|
|
203
|
+
const resolved = path.resolve(projectPath);
|
|
204
|
+
const result = injectAlert(resolved, getBackupStatus(resolved));
|
|
205
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// ── Tool 8: dashboard ───────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
server.tool(
|
|
212
|
+
'dashboard',
|
|
213
|
+
'Get a comprehensive backup health dashboard: strategy, last backup time, backup counts, disk usage breakdown, protection scope, health assessment, and active alerts. Combines status + analytics in one call.',
|
|
214
|
+
{
|
|
215
|
+
path: z.string().describe('Absolute path to the project directory'),
|
|
216
|
+
},
|
|
217
|
+
async ({ path: projectPath }) => {
|
|
218
|
+
const resolved = path.resolve(projectPath);
|
|
219
|
+
const result = injectAlert(resolved, getDashboard(resolved));
|
|
220
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// ── Tool 9: alert_status ────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
server.tool(
|
|
227
|
+
'alert_status',
|
|
228
|
+
'Check if there is an active change-velocity alert (V4 proactive detection). Returns the alert details if active, or confirms no alert. Read-only, safe to call anytime.',
|
|
229
|
+
{
|
|
230
|
+
path: z.string().describe('Absolute path to the project directory'),
|
|
231
|
+
},
|
|
232
|
+
async ({ path: projectPath }) => {
|
|
233
|
+
const resolved = path.resolve(projectPath);
|
|
234
|
+
const alert = loadActiveAlert(resolved);
|
|
235
|
+
const result = alert
|
|
236
|
+
? { active: true, alert }
|
|
237
|
+
: { active: false, message: 'No active alerts' };
|
|
238
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// ── Start ───────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
async function main() {
|
|
245
|
+
const transport = new StdioServerTransport();
|
|
246
|
+
await server.connect(transport);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
main().catch((err) => {
|
|
250
|
+
console.error('cursor-guard MCP server failed to start:', err);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
});
|