Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/mcp/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,25 @@ export interface CodebaseInfoIndexing extends CodebaseInfoBase {
indexingPercentage: number; // Current progress percentage
}

/** Change counts from a successful incremental sync (reindexByChange). */
export interface CodebaseIncrementalSyncStats {
added: number;
removed: number;
modified: number;
}

// Indexed state - when indexing completed successfully
export interface CodebaseInfoIndexed extends CodebaseInfoBase {
status: 'indexed';
indexedFiles: number; // Number of files indexed
totalChunks: number; // Total number of chunks generated
indexStatus: 'completed' | 'limit_reached'; // Status from indexing result
/** ISO timestamp of the last successful full index (index_codebase). */
lastFullIndexAt?: string;
/** ISO timestamp of the last successful incremental sync (background/trigger). */
lastIncrementalSyncAt?: string;
/** Stats from the most recent incremental sync, if any. */
lastSyncStats?: CodebaseIncrementalSyncStats;
}

// Index failed state - when indexing failed
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/src/handlers.get-indexing-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ test("get_indexing_status syncs cloud state before reading the snapshot", async
assert.equal(result.isError, undefined);
assert.match(result.content[0].text, /fully indexed and ready for search/);
assert.match(result.content[0].text, /3 files, 5 chunks/);
assert.match(result.content[0].text, /Last full index:/);
assert.match(result.content[0].text, /Index freshness:/);
assert.equal(snapshotManager.getCodebaseStatus(codebasePath), "indexed");
});
});
18 changes: 17 additions & 1 deletion packages/mcp/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,23 @@ export class ToolHandlers {
statusMessage = `✅ Codebase '${statusCodebasePath}' is fully indexed and ready for search.`;
statusMessage += `\n📊 Statistics: ${indexedInfo.indexedFiles} files, ${indexedInfo.totalChunks} chunks`;
statusMessage += `\n📅 Status: ${indexedInfo.indexStatus}`;
statusMessage += `\n🕐 Last updated: ${new Date(indexedInfo.lastUpdated).toLocaleString()}`;
const lastFullAt = indexedInfo.lastFullIndexAt ?? indexedInfo.lastUpdated;
statusMessage += `\n🕐 Last full index: ${new Date(lastFullAt).toLocaleString()}`;
if (indexedInfo.lastIncrementalSyncAt) {
statusMessage += `\n🔄 Last incremental sync: ${new Date(indexedInfo.lastIncrementalSyncAt).toLocaleString()}`;
const syncStats = indexedInfo.lastSyncStats;
if (syncStats) {
const { added, removed, modified } = syncStats;
if (added === 0 && removed === 0 && modified === 0) {
statusMessage += ' (no file changes)';
} else {
statusMessage += ` (added: ${added}, removed: ${removed}, modified: ${modified})`;
}
}
} else {
statusMessage += `\n🔄 Last incremental sync: not recorded yet (run background/trigger sync to refresh)`;
}
statusMessage += `\n🕐 Index freshness: ${new Date(indexedInfo.lastUpdated).toLocaleString()}`;
} else {
statusMessage = `✅ Codebase '${statusCodebasePath}' is fully indexed and ready for search.`;
}
Expand Down
111 changes: 111 additions & 0 deletions packages/mcp/src/snapshot.incremental-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";

import { SnapshotManager } from "./snapshot.js";

async function withTempHome(run: (homeDir: string) => Promise<void>): Promise<void> {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "claude-context-mcp-snap-inc-"));
const homeDir = path.join(tempRoot, "home");
await mkdir(homeDir, { recursive: true });

const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;

try {
await run(homeDir);
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}

if (originalUserProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = originalUserProfile;
}

await rm(tempRoot, { recursive: true, force: true });
}
}

test("recordIncrementalSyncSuccess updates freshness and persists across reload", async () => {
await withTempHome(async () => {
const codebasePath = path.join(os.tmpdir(), "fake-repo-" + Date.now());
await mkdir(codebasePath, { recursive: true });

const snapshotManager = new SnapshotManager();
snapshotManager.setCodebaseIndexed(codebasePath, {
indexedFiles: 10,
totalChunks: 40,
status: "completed",
});
const afterFull = snapshotManager.getCodebaseInfo(codebasePath);
assert.ok(afterFull && afterFull.status === "indexed");
const fullUpdated = afterFull.lastUpdated;
const fullIndexAt = afterFull.lastFullIndexAt;
assert.ok(fullIndexAt);

await new Promise((r) => setTimeout(r, 5));

const ok = snapshotManager.recordIncrementalSyncSuccess(codebasePath, {
added: 1,
removed: 0,
modified: 2,
});
assert.equal(ok, true);
snapshotManager.saveCodebaseSnapshot();

const afterSync = snapshotManager.getCodebaseInfo(codebasePath);
assert.ok(afterSync && afterSync.status === "indexed");
assert.equal(afterSync.indexedFiles, 11);
assert.equal(afterSync.lastFullIndexAt, fullIndexAt);
assert.ok(afterSync.lastIncrementalSyncAt);
assert.ok(afterSync.lastUpdated >= fullUpdated);
assert.deepEqual(afterSync.lastSyncStats, { added: 1, removed: 0, modified: 2 });

const snapshotPath = path.join(process.env.HOME!, ".context", "mcp-codebase-snapshot.json");
const disk = JSON.parse(await readFile(snapshotPath, "utf8"));
const diskInfo = disk.codebases[codebasePath];
assert.equal(diskInfo.lastIncrementalSyncAt, afterSync.lastIncrementalSyncAt);

const reloaded = new SnapshotManager();
reloaded.loadCodebaseSnapshot();
const fromDisk = reloaded.getCodebaseInfo(codebasePath);
assert.ok(fromDisk && fromDisk.status === "indexed");
assert.equal(fromDisk.lastIncrementalSyncAt, afterSync.lastIncrementalSyncAt);
assert.deepEqual(fromDisk.lastSyncStats, { added: 1, removed: 0, modified: 2 });
});
});

test("recordIncrementalSyncSuccess records zero-change sync", async () => {
await withTempHome(async () => {
const codebasePath = path.join(os.tmpdir(), "fake-repo-zero-" + Date.now());
await mkdir(codebasePath, { recursive: true });

const snapshotManager = new SnapshotManager();
snapshotManager.setCodebaseIndexed(codebasePath, {
indexedFiles: 5,
totalChunks: 20,
status: "completed",
});

const ok = snapshotManager.recordIncrementalSyncSuccess(codebasePath, {
added: 0,
removed: 0,
modified: 0,
});
assert.equal(ok, true);
const info = snapshotManager.getCodebaseInfo(codebasePath);
assert.ok(info && info.status === "indexed");
assert.ok(info.lastIncrementalSyncAt);
assert.deepEqual(info.lastSyncStats, { added: 0, removed: 0, modified: 0 });
assert.equal(info.indexedFiles, 5);
});
});
52 changes: 50 additions & 2 deletions packages/mcp/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
CodebaseIndexOptions,
CodebaseInfoIndexing,
CodebaseInfoIndexed,
CodebaseInfoIndexFailed
CodebaseInfoIndexFailed,
CodebaseIncrementalSyncStats
} from "./config.js";

export class SnapshotManager {
Expand Down Expand Up @@ -444,15 +445,62 @@ export class SnapshotManager {

const resolvedIndexOptions = this.resolveIndexOptions(codebasePath, indexOptions);

const now = new Date().toISOString();
const existing = this.codebaseInfoMap.get(codebasePath);
const preservedIncremental =
existing && existing.status === 'indexed'
? {
lastIncrementalSyncAt: existing.lastIncrementalSyncAt,
lastSyncStats: existing.lastSyncStats,
}
: {};

const info: CodebaseInfoIndexed = {
status: 'indexed',
indexedFiles: stats.indexedFiles,
totalChunks: stats.totalChunks,
indexStatus: stats.status,
...resolvedIndexOptions,
lastUpdated: new Date().toISOString()
lastUpdated: now,
lastFullIndexAt: now,
...preservedIncremental,
};
this.codebaseInfoMap.set(codebasePath, info);
}

/**
* Record a successful incremental sync without rewriting full-index metadata.
* Updates freshness timestamps and optional file-count delta from sync stats.
*/
public recordIncrementalSyncSuccess(
codebasePath: string,
syncStats: CodebaseIncrementalSyncStats
): boolean {
const existing = this.codebaseInfoMap.get(codebasePath);
if (!existing || existing.status !== 'indexed') {
console.warn(
`[SNAPSHOT] Skipping incremental sync metadata for '${codebasePath}': not in indexed state`
);
return false;
}

const now = new Date().toISOString();
const indexedFiles = Math.max(
0,
existing.indexedFiles + syncStats.added - syncStats.removed
);

const info: CodebaseInfoIndexed = {
...existing,
indexedFiles,
lastUpdated: now,
lastFullIndexAt: existing.lastFullIndexAt ?? existing.lastUpdated,
lastIncrementalSyncAt: now,
lastSyncStats: { ...syncStats },
};
this.codebaseInfoMap.set(codebasePath, info);
this.codebaseFileCount.set(codebasePath, indexedFiles);
return true;
}

/**
Expand Down
71 changes: 71 additions & 0 deletions packages/mcp/src/sync.incremental-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";

import { SyncManager } from "./sync.js";
import { SnapshotManager } from "./snapshot.js";

async function withTempHome(run: (tempRoot: string) => Promise<void>): Promise<void> {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "claude-context-mcp-sync-meta-"));
const homeDir = path.join(tempRoot, "home");
await mkdir(homeDir, { recursive: true });

const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;

try {
await run(tempRoot);
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}

if (originalUserProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = originalUserProfile;
}

await rm(tempRoot, { recursive: true, force: true });
}
}

test("handleSyncIndex persists incremental sync metadata after reindexByChange", async () => {
await withTempHome(async (tempRoot) => {
const codebasePath = path.join(tempRoot, "repo");
await mkdir(codebasePath, { recursive: true });

const snapshotManager = new SnapshotManager();
snapshotManager.setCodebaseIndexed(codebasePath, {
indexedFiles: 4,
totalChunks: 12,
status: "completed",
});
snapshotManager.saveCodebaseSnapshot();

const before = snapshotManager.getCodebaseInfo(codebasePath);
assert.ok(before && before.status === "indexed");
assert.equal(before.lastIncrementalSyncAt, undefined);

const mockContext = {
async reindexByChange() {
return { added: 0, removed: 0, modified: 1 };
},
};

const syncManager = new SyncManager(mockContext as any, snapshotManager);
await syncManager.handleSyncIndex();

const after = snapshotManager.getCodebaseInfo(codebasePath);
assert.ok(after && after.status === "indexed");
assert.ok(after.lastIncrementalSyncAt);
assert.deepEqual(after.lastSyncStats, { added: 0, removed: 0, modified: 1 });
assert.ok(after.lastUpdated >= before.lastUpdated);
});
});
5 changes: 5 additions & 0 deletions packages/mcp/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ export class SyncManager {
totalStats.removed += stats.removed;
totalStats.modified += stats.modified;

const recorded = this.snapshotManager.recordIncrementalSyncSuccess(codebasePath, stats);
if (recorded) {
this.snapshotManager.saveCodebaseSnapshot();
}

if (stats.added > 0 || stats.removed > 0 || stats.modified > 0) {
console.log(`[SYNC] Sync complete for '${codebasePath}'. Added: ${stats.added}, Removed: ${stats.removed}, Modified: ${stats.modified} (${codebaseElapsed}ms)`);
} else {
Expand Down