ronds-mcp-tracker 0.1.6 → 0.1.8

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 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
- - `old_string`: previous content
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 changed
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/config.js CHANGED
@@ -9,7 +9,6 @@ export const FAILED_QUEUE_CONFIG = {
9
9
  };
10
10
  function resolveWorkerId() {
11
11
  const localConfigPath = path.resolve(process.cwd(), '.ai_config', 'config.json');
12
- console.log('[mcp-tracker] localConfigPath:', localConfigPath);
13
12
  try {
14
13
  if (fs.existsSync(localConfigPath)) {
15
14
  const raw = fs.readFileSync(localConfigPath, 'utf-8');
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import { execSync } from 'node:child_process';
4
5
  import { randomUUID } from 'node:crypto';
5
6
  import { diffLines } from 'diff';
6
7
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@@ -40,12 +41,27 @@ function getRepoNameFromGitConfig(cwd) {
40
41
  return null;
41
42
  }
42
43
  }
44
+ function getGitRepoRoot(cwd) {
45
+ try {
46
+ const output = execSync('git rev-parse --show-toplevel', {
47
+ cwd,
48
+ encoding: 'utf8',
49
+ stdio: ['ignore', 'pipe', 'ignore']
50
+ });
51
+ const repoRoot = output.trim();
52
+ return repoRoot || null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
43
58
  async function getGitInfo(cwd) {
44
- const repoNameFromGitConfig = getRepoNameFromGitConfig(cwd);
59
+ const repoRoot = getGitRepoRoot(cwd) ?? cwd;
60
+ const repoNameFromGitConfig = getRepoNameFromGitConfig(repoRoot);
45
61
  if (repoNameFromGitConfig) {
46
- return { repo_name: repoNameFromGitConfig };
62
+ return { repo_name: repoNameFromGitConfig, repo_root: repoRoot };
47
63
  }
48
- const configPath = path.join(cwd, '.gitlab', 'config.json');
64
+ const configPath = path.join(repoRoot, '.gitlab', 'config.json');
49
65
  if (!fs.existsSync(configPath)) {
50
66
  return null;
51
67
  }
@@ -56,12 +72,25 @@ async function getGitInfo(cwd) {
56
72
  if (!repoName) {
57
73
  return null;
58
74
  }
59
- return { repo_name: repoName };
75
+ return { repo_name: repoName, repo_root: repoRoot };
60
76
  }
61
77
  catch {
62
78
  return null;
63
79
  }
64
80
  }
81
+ function toRepoRelativePath(filePath, repoRoot) {
82
+ if (!repoRoot) {
83
+ return filePath;
84
+ }
85
+ const relativePath = path.relative(repoRoot, filePath);
86
+ if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
87
+ return filePath;
88
+ }
89
+ return relativePath.replace(/\\/g, '/');
90
+ }
91
+ function getKafkaFilePath(filePath, gitInfo) {
92
+ return toRepoRelativePath(filePath, gitInfo?.repo_root);
93
+ }
65
94
  function getAddedLines(oldStr, newStr) {
66
95
  const parts = diffLines(oldStr, newStr);
67
96
  let lineCount = 0;
@@ -97,9 +126,75 @@ function countDiff(oldStr, newStr) {
97
126
  }
98
127
  return { added, removed };
99
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
+ }
100
147
  function getFailedDir() {
101
148
  return path.join(os.homedir(), '.mcp-tracker', 'failed');
102
149
  }
150
+ function getSnapshotsDir() {
151
+ return path.join(os.homedir(), '.mcp-tracker', 'snapshots');
152
+ }
153
+ function ensureSnapshotsDirExists() {
154
+ const dir = getSnapshotsDir();
155
+ if (!fs.existsSync(dir)) {
156
+ fs.mkdirSync(dir, { recursive: true });
157
+ }
158
+ return dir;
159
+ }
160
+ function getSnapshotPath(snapshotId) {
161
+ return path.join(getSnapshotsDir(), `${snapshotId}.json`);
162
+ }
163
+ function cleanupSnapshotsOnStartup() {
164
+ const dir = ensureSnapshotsDirExists();
165
+ const entries = fs.readdirSync(dir);
166
+ let deleted = 0;
167
+ for (const name of entries) {
168
+ const filePath = path.join(dir, name);
169
+ const stat = fs.statSync(filePath);
170
+ if (!stat.isFile()) {
171
+ continue;
172
+ }
173
+ fs.unlinkSync(filePath);
174
+ deleted += 1;
175
+ }
176
+ return { status: 'cleaned', deleted_count: deleted };
177
+ }
178
+ function saveSnapshot(snapshot) {
179
+ const dir = ensureSnapshotsDirExists();
180
+ const filePath = path.join(dir, `${snapshot.snapshot_id}.json`);
181
+ fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2), 'utf8');
182
+ return filePath;
183
+ }
184
+ function loadSnapshot(snapshotId) {
185
+ const filePath = getSnapshotPath(snapshotId);
186
+ if (!fs.existsSync(filePath)) {
187
+ return null;
188
+ }
189
+ const raw = fs.readFileSync(filePath, 'utf8');
190
+ return JSON.parse(raw);
191
+ }
192
+ function deleteSnapshot(snapshotId) {
193
+ const filePath = getSnapshotPath(snapshotId);
194
+ if (fs.existsSync(filePath)) {
195
+ fs.unlinkSync(filePath);
196
+ }
197
+ }
103
198
  function cleanupFailed(maxAgeHours = 168) {
104
199
  const dir = getFailedDir();
105
200
  if (!fs.existsSync(dir)) {
@@ -167,23 +262,80 @@ async function replayFailed(maxCount, config) {
167
262
  }
168
263
  return { status: 'completed', replayed, failed };
169
264
  }
265
+ function validateFilePath(filePath) {
266
+ const normalizedPath = filePath.trim();
267
+ if (!normalizedPath) {
268
+ throw new Error('file_path is required');
269
+ }
270
+ return normalizedPath;
271
+ }
272
+ function readFileContent(filePath) {
273
+ return fs.readFileSync(filePath, 'utf8');
274
+ }
275
+ function getFailedEventPath(eventId) {
276
+ return path.join(getFailedDir(), `${eventId}.json`);
277
+ }
278
+ function handleBeforeEdit(input) {
279
+ const filePath = validateFilePath(input.file_path);
280
+ const content = fs.existsSync(filePath) ? readFileContent(filePath) : '';
281
+ const snapshotId = randomUUID();
282
+ saveSnapshot({
283
+ snapshot_id: snapshotId,
284
+ file_path: filePath,
285
+ created_at: new Date().toISOString(),
286
+ content
287
+ });
288
+ return JSON.stringify({
289
+ status: 'snapshotted',
290
+ snapshot_id: snapshotId,
291
+ file_path: filePath
292
+ });
293
+ }
170
294
  async function handleAfterEdit(input, config) {
171
- const addedLines = getAddedLines(input.old_string, input.new_string);
172
- const diffStats = countDiff(input.old_string, input.new_string);
173
- const gitInfo = await getGitInfo(process.cwd());
174
- const event = {
175
- event_id: randomUUID(),
176
- timestamp: new Date().toISOString(),
177
- worker_id: config.worker_id,
178
- git: gitInfo,
179
- file: { path: input.file_path, operation: 'edit' },
180
- changes: {
181
- added_content: addedLines,
182
- added: diffStats.added,
183
- removed: diffStats.removed
184
- }
185
- };
295
+ const filePath = validateFilePath(input.file_path);
296
+ const snapshotId = input.snapshot_id?.trim();
297
+ if (!snapshotId) {
298
+ throw new Error('snapshot_id is required');
299
+ }
300
+ const snapshot = loadSnapshot(snapshotId);
301
+ if (!snapshot) {
302
+ return JSON.stringify({
303
+ status: 'snapshot_not_found',
304
+ snapshot_id: snapshotId,
305
+ file_path: filePath
306
+ });
307
+ }
308
+ if (snapshot.file_path !== filePath) {
309
+ return JSON.stringify({
310
+ status: 'snapshot_file_path_mismatch',
311
+ snapshot_id: snapshotId,
312
+ file_path: filePath,
313
+ snapshot_file_path: snapshot.file_path
314
+ });
315
+ }
316
+ const oldStr = snapshot.content;
317
+ const newStr = readFileContent(filePath);
318
+ const addedLines = getAddedLines(oldStr, newStr);
319
+ const diffStats = countDiff(oldStr, newStr);
320
+ const event = await buildChangeEvent({
321
+ filePath,
322
+ operation: 'edit',
323
+ addedContent: addedLines,
324
+ added: diffStats.added,
325
+ removed: diffStats.removed,
326
+ config
327
+ });
186
328
  await publishToKafka(config, event);
329
+ if (fs.existsSync(getFailedEventPath(event.event_id))) {
330
+ return JSON.stringify({
331
+ status: 'queued_for_retry',
332
+ event_id: event.event_id,
333
+ snapshot_id: snapshotId,
334
+ lines_changed: diffStats.added + diffStats.removed,
335
+ added_content: addedLines
336
+ });
337
+ }
338
+ deleteSnapshot(snapshotId);
187
339
  return JSON.stringify({
188
340
  status: 'recorded',
189
341
  event_id: event.event_id,
@@ -191,6 +343,30 @@ async function handleAfterEdit(input, config) {
191
343
  added_content: addedLines
192
344
  });
193
345
  }
346
+ async function handleDirectAdd(input, config) {
347
+ const filePath = validateFilePath(input.file_path);
348
+ const addedContent = input.added_content;
349
+ if (typeof addedContent !== 'string' || !addedContent.trim()) {
350
+ throw new Error('added_content is required');
351
+ }
352
+ const added = countNonEmptyLines(addedContent);
353
+ const event = await buildChangeEvent({
354
+ filePath,
355
+ operation: 'add',
356
+ addedContent,
357
+ added,
358
+ removed: 0,
359
+ config
360
+ });
361
+ await publishToKafka(config, event);
362
+ const status = fs.existsSync(getFailedEventPath(event.event_id)) ? 'queued_for_retry' : 'recorded';
363
+ return JSON.stringify({
364
+ status,
365
+ event_id: event.event_id,
366
+ lines_changed: added,
367
+ added_content: addedContent
368
+ });
369
+ }
194
370
  function handleCleanupFailed(input) {
195
371
  const maxAgeHours = input.max_age_hours ?? 168;
196
372
  const result = cleanupFailed(maxAgeHours);
@@ -201,17 +377,39 @@ async function handleReplayFailed(input, config) {
201
377
  const result = await replayFailed(maxCount, config);
202
378
  return JSON.stringify({ status: result.status, replayed: result.replayed, failed: result.failed });
203
379
  }
380
+ const BEFORE_EDIT_TOOL_SCHEMA = {
381
+ name: 'before_edit',
382
+ description: 'Save a server-side snapshot before file edits.',
383
+ inputSchema: {
384
+ type: 'object',
385
+ properties: {
386
+ file_path: { type: 'string' }
387
+ },
388
+ required: ['file_path']
389
+ }
390
+ };
204
391
  const AFTER_EDIT_TOOL_SCHEMA = {
205
392
  name: 'after_edit',
206
- description: 'Record code changes after file edits.',
393
+ description: 'Record code changes after file edits using a saved snapshot.',
394
+ inputSchema: {
395
+ type: 'object',
396
+ properties: {
397
+ file_path: { type: 'string' },
398
+ snapshot_id: { type: 'string' }
399
+ },
400
+ required: ['file_path', 'snapshot_id']
401
+ }
402
+ };
403
+ const DIRECT_ADD_TOOL_SCHEMA = {
404
+ name: 'direct_add',
405
+ description: 'Record added content directly when no pre-edit snapshot is available.',
207
406
  inputSchema: {
208
407
  type: 'object',
209
408
  properties: {
210
409
  file_path: { type: 'string' },
211
- old_string: { type: 'string' },
212
- new_string: { type: 'string' }
410
+ added_content: { type: 'string' }
213
411
  },
214
- required: ['file_path', 'old_string', 'new_string']
412
+ required: ['file_path', 'added_content']
215
413
  }
216
414
  };
217
415
  const CLEANUP_FAILED_TOOL_SCHEMA = {
@@ -236,6 +434,7 @@ const REPLAY_FAILED_TOOL_SCHEMA = {
236
434
  };
237
435
  async function main() {
238
436
  const config = loadConfig();
437
+ cleanupSnapshotsOnStartup();
239
438
  await initProducer(config);
240
439
  const server = new Server({
241
440
  name: 'code-change-tracker',
@@ -246,15 +445,23 @@ async function main() {
246
445
  }
247
446
  });
248
447
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
249
- tools: [AFTER_EDIT_TOOL_SCHEMA, CLEANUP_FAILED_TOOL_SCHEMA, REPLAY_FAILED_TOOL_SCHEMA]
448
+ tools: [BEFORE_EDIT_TOOL_SCHEMA, AFTER_EDIT_TOOL_SCHEMA, DIRECT_ADD_TOOL_SCHEMA, CLEANUP_FAILED_TOOL_SCHEMA, REPLAY_FAILED_TOOL_SCHEMA]
250
449
  }));
251
450
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
252
451
  const name = request.params.name;
253
452
  const input = request.params.arguments ?? {};
453
+ if (name === 'before_edit') {
454
+ const result = handleBeforeEdit(input);
455
+ return { content: [{ type: 'text', text: result }] };
456
+ }
254
457
  if (name === 'after_edit') {
255
458
  const result = await handleAfterEdit(input, config);
256
459
  return { content: [{ type: 'text', text: result }] };
257
460
  }
461
+ if (name === 'direct_add') {
462
+ const result = await handleDirectAdd(input, config);
463
+ return { content: [{ type: 'text', text: result }] };
464
+ }
258
465
  if (name === 'cleanup_failed') {
259
466
  const result = handleCleanupFailed(input);
260
467
  return { content: [{ type: 'text', text: result }] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ronds-mcp-tracker",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ronds-mcp-tracker": "./bin/cli.js"