ronds-mcp-tracker 0.1.7 → 0.1.9
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 +61 -4
- package/dist/server.js +88 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,18 +47,43 @@ Custom worker id:
|
|
|
47
47
|
- Kafka config is currently hardcoded (see `src/config.ts`) and can be extended later.
|
|
48
48
|
|
|
49
49
|
## Tools
|
|
50
|
+
### `before_edit`
|
|
51
|
+
适用场景:支持在编辑前先调用 MCP 的客户端。服务端会保存编辑前快照,并返回 `snapshot_id` 供后续 `after_edit` 使用。
|
|
52
|
+
|
|
53
|
+
Input:
|
|
54
|
+
- `file_path`: file path
|
|
55
|
+
|
|
56
|
+
Output:
|
|
57
|
+
- `status`: `snapshotted`
|
|
58
|
+
- `snapshot_id`: snapshot id
|
|
59
|
+
- `file_path`: normalized file path
|
|
60
|
+
|
|
50
61
|
### `after_edit`
|
|
62
|
+
适用场景:基于 `before_edit` 保存的快照,在编辑后计算完整 diff。该模式能区分新增与删除,`file.operation` 为 `edit`。
|
|
63
|
+
|
|
51
64
|
Input:
|
|
52
65
|
- `file_path`: file path
|
|
53
|
-
- `
|
|
54
|
-
- `new_string`: new content
|
|
66
|
+
- `snapshot_id`: snapshot id returned by `before_edit`
|
|
55
67
|
|
|
56
68
|
Output:
|
|
57
|
-
- `status`: `recorded`
|
|
69
|
+
- `status`: `recorded` or `queued_for_retry`
|
|
58
70
|
- `event_id`: event id
|
|
59
|
-
- `lines_changed`: total lines
|
|
71
|
+
- `lines_changed`: total changed lines (`added + removed`)
|
|
60
72
|
- `added_content`: added lines content (may be truncated)
|
|
61
73
|
|
|
74
|
+
### `direct_add`
|
|
75
|
+
适用场景:客户端无法在编辑前调用 MCP,只能在编辑完成后上报新增内容时使用。该模式不依赖 snapshot,直接记录新增内容,`file.operation` 为 `add`。
|
|
76
|
+
|
|
77
|
+
Input:
|
|
78
|
+
- `file_path`: file path
|
|
79
|
+
- `added_content`: newly added content supplied by the client
|
|
80
|
+
|
|
81
|
+
Output:
|
|
82
|
+
- `status`: `recorded` or `queued_for_retry`
|
|
83
|
+
- `event_id`: event id
|
|
84
|
+
- `lines_changed`: non-empty line count in `added_content`
|
|
85
|
+
- `added_content`: original added content
|
|
86
|
+
|
|
62
87
|
### `cleanup_failed`
|
|
63
88
|
Input:
|
|
64
89
|
- `max_age_hours` (optional): default 168
|
|
@@ -79,6 +104,38 @@ Output:
|
|
|
79
104
|
## Failed Queue
|
|
80
105
|
Failed events are stored in `~/.mcp-tracker/failed` as JSON files. Old files are cleaned by age and replayed in FIFO order.
|
|
81
106
|
|
|
107
|
+
## Usage Flow
|
|
108
|
+
### Snapshot mode (`before_edit` + `after_edit`)
|
|
109
|
+
适用于 Claude Code 这类可以在编辑前后分别调用 MCP 的客户端:
|
|
110
|
+
|
|
111
|
+
1. 调用 `before_edit` 保存服务端快照
|
|
112
|
+
2. 完成文件编辑
|
|
113
|
+
3. 调用 `after_edit` 并传入 `snapshot_id`
|
|
114
|
+
|
|
115
|
+
说明:
|
|
116
|
+
- 如果目标文件在 `before_edit` 时还不存在,服务端会保存一个空快照。
|
|
117
|
+
- 随后 `after_edit` 会把该文件视为“从空内容新增”,正常上传新增内容。
|
|
118
|
+
- 这种场景下事件类型仍然是 `file.operation = edit`,用于保持 snapshot 模式的一致性。
|
|
119
|
+
|
|
120
|
+
### Direct add mode (`direct_add`)
|
|
121
|
+
适用于 Cursor 这类通常只能在编辑完成后调用 MCP 的客户端:
|
|
122
|
+
|
|
123
|
+
1. 客户端拿到本次新增内容
|
|
124
|
+
2. 调用 `direct_add(file_path, added_content)`
|
|
125
|
+
3. 服务端直接构建 `ChangeEvent` 并发布到 Kafka
|
|
126
|
+
|
|
127
|
+
适用建议:
|
|
128
|
+
- 能走 `before_edit` / `after_edit` 时,优先使用 snapshot 模式。
|
|
129
|
+
- 客户端只能后调用、拿不到编辑前快照时,再使用 `direct_add` 兜底。
|
|
130
|
+
|
|
131
|
+
### Direct add mode (`direct_add`)
|
|
132
|
+
适用于 Cursor 这类通常只能在编辑完成后调用 MCP 的客户端:
|
|
133
|
+
|
|
134
|
+
1. 客户端拿到本次新增内容
|
|
135
|
+
2. 调用 `direct_add(file_path, added_content)`
|
|
136
|
+
3. 服务端直接构建 `ChangeEvent` 并发布到 Kafka
|
|
137
|
+
|
|
82
138
|
## Development
|
|
83
139
|
- Build: `npm run build`
|
|
84
140
|
- Test tools list: `echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node dist/server.js`
|
|
141
|
+
- Test direct add locally: call `tools/call` with `direct_add`, `file_path`, and multi-line `added_content`
|
package/dist/server.js
CHANGED
|
@@ -126,6 +126,24 @@ function countDiff(oldStr, newStr) {
|
|
|
126
126
|
}
|
|
127
127
|
return { added, removed };
|
|
128
128
|
}
|
|
129
|
+
function countNonEmptyLines(content) {
|
|
130
|
+
return content.split('\n').filter((line) => line !== '').length;
|
|
131
|
+
}
|
|
132
|
+
async function buildChangeEvent(params) {
|
|
133
|
+
const gitInfo = await getGitInfo(process.cwd());
|
|
134
|
+
return {
|
|
135
|
+
event_id: randomUUID(),
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
worker_id: params.config.worker_id,
|
|
138
|
+
git: gitInfo ? { repo_name: gitInfo.repo_name } : null,
|
|
139
|
+
file: { path: getKafkaFilePath(params.filePath, gitInfo), operation: params.operation },
|
|
140
|
+
changes: {
|
|
141
|
+
added_content: params.addedContent,
|
|
142
|
+
added: params.added,
|
|
143
|
+
removed: params.removed
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
129
147
|
function getFailedDir() {
|
|
130
148
|
return path.join(os.homedir(), '.mcp-tracker', 'failed');
|
|
131
149
|
}
|
|
@@ -171,6 +189,24 @@ function loadSnapshot(snapshotId) {
|
|
|
171
189
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
172
190
|
return JSON.parse(raw);
|
|
173
191
|
}
|
|
192
|
+
function loadLatestSnapshotForFile(filePath) {
|
|
193
|
+
const dir = ensureSnapshotsDirExists();
|
|
194
|
+
const snapshots = fs.readdirSync(dir)
|
|
195
|
+
.map((name) => path.join(dir, name))
|
|
196
|
+
.filter((snapshotPath) => fs.statSync(snapshotPath).isFile())
|
|
197
|
+
.map((snapshotPath) => {
|
|
198
|
+
try {
|
|
199
|
+
const raw = fs.readFileSync(snapshotPath, 'utf8');
|
|
200
|
+
return JSON.parse(raw);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
.filter((snapshot) => snapshot !== null && snapshot.file_path === filePath)
|
|
207
|
+
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
208
|
+
return snapshots[0] ?? null;
|
|
209
|
+
}
|
|
174
210
|
function deleteSnapshot(snapshotId) {
|
|
175
211
|
const filePath = getSnapshotPath(snapshotId);
|
|
176
212
|
if (fs.existsSync(filePath)) {
|
|
@@ -259,7 +295,7 @@ function getFailedEventPath(eventId) {
|
|
|
259
295
|
}
|
|
260
296
|
function handleBeforeEdit(input) {
|
|
261
297
|
const filePath = validateFilePath(input.file_path);
|
|
262
|
-
const content = readFileContent(filePath);
|
|
298
|
+
const content = fs.existsSync(filePath) ? readFileContent(filePath) : '';
|
|
263
299
|
const snapshotId = randomUUID();
|
|
264
300
|
saveSnapshot({
|
|
265
301
|
snapshot_id: snapshotId,
|
|
@@ -279,55 +315,54 @@ async function handleAfterEdit(input, config) {
|
|
|
279
315
|
if (!snapshotId) {
|
|
280
316
|
throw new Error('snapshot_id is required');
|
|
281
317
|
}
|
|
282
|
-
const snapshot = loadSnapshot(snapshotId);
|
|
318
|
+
const snapshot = loadSnapshot(snapshotId) ?? loadLatestSnapshotForFile(filePath);
|
|
283
319
|
if (!snapshot) {
|
|
284
|
-
return JSON.stringify({
|
|
285
|
-
status: 'snapshot_not_found',
|
|
286
|
-
snapshot_id: snapshotId,
|
|
287
|
-
file_path: filePath
|
|
288
|
-
});
|
|
320
|
+
return JSON.stringify({ success: false });
|
|
289
321
|
}
|
|
290
322
|
if (snapshot.file_path !== filePath) {
|
|
291
|
-
return JSON.stringify({
|
|
292
|
-
status: 'snapshot_file_path_mismatch',
|
|
293
|
-
snapshot_id: snapshotId,
|
|
294
|
-
file_path: filePath,
|
|
295
|
-
snapshot_file_path: snapshot.file_path
|
|
296
|
-
});
|
|
323
|
+
return JSON.stringify({ success: false });
|
|
297
324
|
}
|
|
298
325
|
const oldStr = snapshot.content;
|
|
299
326
|
const newStr = readFileContent(filePath);
|
|
300
327
|
const addedLines = getAddedLines(oldStr, newStr);
|
|
301
328
|
const diffStats = countDiff(oldStr, newStr);
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
added_content: addedLines,
|
|
311
|
-
added: diffStats.added,
|
|
312
|
-
removed: diffStats.removed
|
|
313
|
-
}
|
|
314
|
-
};
|
|
329
|
+
const event = await buildChangeEvent({
|
|
330
|
+
filePath,
|
|
331
|
+
operation: 'edit',
|
|
332
|
+
addedContent: addedLines,
|
|
333
|
+
added: diffStats.added,
|
|
334
|
+
removed: diffStats.removed,
|
|
335
|
+
config
|
|
336
|
+
});
|
|
315
337
|
await publishToKafka(config, event);
|
|
316
338
|
if (fs.existsSync(getFailedEventPath(event.event_id))) {
|
|
317
|
-
return JSON.stringify({
|
|
318
|
-
status: 'queued_for_retry',
|
|
319
|
-
event_id: event.event_id,
|
|
320
|
-
snapshot_id: snapshotId,
|
|
321
|
-
lines_changed: diffStats.added + diffStats.removed,
|
|
322
|
-
added_content: addedLines
|
|
323
|
-
});
|
|
339
|
+
return JSON.stringify({ success: false });
|
|
324
340
|
}
|
|
325
341
|
deleteSnapshot(snapshotId);
|
|
342
|
+
return JSON.stringify({ success: true });
|
|
343
|
+
}
|
|
344
|
+
async function handleDirectAdd(input, config) {
|
|
345
|
+
const filePath = validateFilePath(input.file_path);
|
|
346
|
+
const addedContent = input.added_content;
|
|
347
|
+
if (typeof addedContent !== 'string' || !addedContent.trim()) {
|
|
348
|
+
throw new Error('added_content is required');
|
|
349
|
+
}
|
|
350
|
+
const added = countNonEmptyLines(addedContent);
|
|
351
|
+
const event = await buildChangeEvent({
|
|
352
|
+
filePath,
|
|
353
|
+
operation: 'add',
|
|
354
|
+
addedContent,
|
|
355
|
+
added,
|
|
356
|
+
removed: 0,
|
|
357
|
+
config
|
|
358
|
+
});
|
|
359
|
+
await publishToKafka(config, event);
|
|
360
|
+
const status = fs.existsSync(getFailedEventPath(event.event_id)) ? 'queued_for_retry' : 'recorded';
|
|
326
361
|
return JSON.stringify({
|
|
327
|
-
status
|
|
362
|
+
status,
|
|
328
363
|
event_id: event.event_id,
|
|
329
|
-
lines_changed:
|
|
330
|
-
added_content:
|
|
364
|
+
lines_changed: added,
|
|
365
|
+
added_content: addedContent
|
|
331
366
|
});
|
|
332
367
|
}
|
|
333
368
|
function handleCleanupFailed(input) {
|
|
@@ -363,6 +398,18 @@ const AFTER_EDIT_TOOL_SCHEMA = {
|
|
|
363
398
|
required: ['file_path', 'snapshot_id']
|
|
364
399
|
}
|
|
365
400
|
};
|
|
401
|
+
const DIRECT_ADD_TOOL_SCHEMA = {
|
|
402
|
+
name: 'direct_add',
|
|
403
|
+
description: 'Record added content directly when no pre-edit snapshot is available.',
|
|
404
|
+
inputSchema: {
|
|
405
|
+
type: 'object',
|
|
406
|
+
properties: {
|
|
407
|
+
file_path: { type: 'string' },
|
|
408
|
+
added_content: { type: 'string' }
|
|
409
|
+
},
|
|
410
|
+
required: ['file_path', 'added_content']
|
|
411
|
+
}
|
|
412
|
+
};
|
|
366
413
|
const CLEANUP_FAILED_TOOL_SCHEMA = {
|
|
367
414
|
name: 'cleanup_failed',
|
|
368
415
|
description: 'Clean up failed queue files by age.',
|
|
@@ -396,7 +443,7 @@ async function main() {
|
|
|
396
443
|
}
|
|
397
444
|
});
|
|
398
445
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
399
|
-
tools: [BEFORE_EDIT_TOOL_SCHEMA, AFTER_EDIT_TOOL_SCHEMA, CLEANUP_FAILED_TOOL_SCHEMA, REPLAY_FAILED_TOOL_SCHEMA]
|
|
446
|
+
tools: [BEFORE_EDIT_TOOL_SCHEMA, AFTER_EDIT_TOOL_SCHEMA, DIRECT_ADD_TOOL_SCHEMA, CLEANUP_FAILED_TOOL_SCHEMA, REPLAY_FAILED_TOOL_SCHEMA]
|
|
400
447
|
}));
|
|
401
448
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
402
449
|
const name = request.params.name;
|
|
@@ -409,6 +456,10 @@ async function main() {
|
|
|
409
456
|
const result = await handleAfterEdit(input, config);
|
|
410
457
|
return { content: [{ type: 'text', text: result }] };
|
|
411
458
|
}
|
|
459
|
+
if (name === 'direct_add') {
|
|
460
|
+
const result = await handleDirectAdd(input, config);
|
|
461
|
+
return { content: [{ type: 'text', text: result }] };
|
|
462
|
+
}
|
|
412
463
|
if (name === 'cleanup_failed') {
|
|
413
464
|
const result = handleCleanupFailed(input);
|
|
414
465
|
return { content: [{ type: 'text', text: result }] };
|