From 3567751591cc8b1e3d179813fdb47148d28dcf6c Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 07:11:37 +0200 Subject: [PATCH 1/7] Add compare_directories tool for directory comparison --- src/filesystem/index.ts | 56 +++++++++++++++++++++++++++++ src/filesystem/lib.ts | 78 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..efc9747d25 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -702,6 +702,62 @@ server.registerTool( } ); +server.registerTool( + "compare_directories", + { + title: "Compare Directories", + description: + "Compare two directories and report differences. Returns files only in first dir, " + + "only in second dir, files with different content (size/mtime), and identical files. " + + "Useful for syncing and finding changes between directory versions.", + inputSchema: { + dir1: z.string(), + dir2: z.string(), + compareContent: z.boolean().optional() + }, + outputSchema: { content: z.string() }, + annotations: { readOnlyHint: true } + }, + async (args: { dir1: string; dir2: string; compareContent?: boolean }) => { + const validDir1 = await validatePath(args.dir1); + const validDir2 = await validatePath(args.dir2); + const result = await compareDirectories(validDir1, validDir2, args.compareContent); + + const lines: string[] = []; + lines.push(`Comparison: ${args.dir1} vs ${args.dir2}`); + lines.push(""); + + if (result.onlyInDir1.length > 0) { + lines.push(`Only in ${args.dir1} (${result.onlyInDir1.length}):`); + result.onlyInDir1.forEach(f => lines.push(` - ${f}`)); + lines.push(""); + } + + if (result.onlyInDir2.length > 0) { + lines.push(`Only in ${args.dir2} (${result.onlyInDir2.length}):`); + result.onlyInDir2.forEach(f => lines.push(` - ${f}`)); + lines.push(""); + } + + if (result.differentContent.length > 0) { + lines.push(`Different content (${result.differentContent.length}):`); + result.differentContent.forEach(f => { + lines.push(` - ${f.path}`); + lines.push(` Size: ${f.dir1Size} vs ${f.dir2Size}`); + }); + lines.push(""); + } + + lines.push(`Identical files: ${result.identical.length}`); + + const text = lines.join("\n"); + return { + content: [{ type: "text" as const, text }], + structuredContent: { result } + }; + } +); + // Updates allowed directories based on MCP client roots async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) { const validatedRootDirs = await getValidRootDirectories(requestedRoots); diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 17e4654cd5..158e68061e 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -413,3 +413,81 @@ export async function searchFilesWithValidation( await search(rootPath); return results; } + +export interface DirectoryComparisonResult { + onlyInDir1: string[]; + onlyInDir2: string[]; + differentContent: Array<{ + path: string; + dir1Size: number; + dir2Size: number; + dir1Mtime: number; + dir2Mtime: number; + }>; + identical: string[]; +} + +export async function compareDirectories( + dir1: string, + dir2: string, + compareContent: boolean = false +): Promise { + const dir1Files = await searchFiles(dir1, "**/*"); + const dir2Files = await searchFiles(dir2, "**/*"); + + const dir1Set = new Set(dir1Files.map(f => path.relative(dir1, f))); + const dir2Set = new Set(dir2Files.map(f => path.relative(dir2, f))); + + const onlyInDir1: string[] = []; + const onlyInDir2: string[] = []; + const differentContent: DirectoryComparisonResult["differentContent"] = []; + const identical: string[] = []; + + // Files only in dir1 + for (const file of dir1Files) { + const relPath = path.relative(dir1, file); + if (!dir2Set.has(relPath)) { + onlyInDir1.push(relPath); + } + } + + // Files only in dir2 + for (const file of dir2Files) { + const relPath = path.relative(dir2, file); + if (!dir1Set.has(relPath)) { + onlyInDir2.push(relPath); + } + } + + // Compare common files + for (const relPath of dir1Set) { + if (dir2Set.has(relPath)) { + const file1 = path.join(dir1, relPath); + const file2 = path.join(dir2, relPath); + + const [stat1, stat2] = await Promise.all([ + fs.stat(file1), + fs.stat(file2) + ]); + + if (stat1.size !== stat2.size || stat1.mtimeMs !== stat2.mtimeMs) { + differentContent.push({ + path: relPath, + dir1Size: stat1.size, + dir2Size: stat2.size, + dir1Mtime: stat1.mtimeMs, + dir2Mtime: stat2.mtimeMs + }); + } else { + identical.push(relPath); + } + } + } + + return { + onlyInDir1, + onlyInDir2, + differentContent, + identical + }; +} From cc7ea31b6059b2c3d3536ddd72f451e5b83b978e Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 07:42:22 +0200 Subject: [PATCH 2/7] Add tests for compare_directories function --- src/filesystem/__tests__/lib.test.ts | 77 ++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index f7e585af22..8a8c039bc8 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -723,3 +723,80 @@ describe('Lib Functions', () => { }); }); }); + +describe("compareDirectories", () => { + const testDir1 = path.join(os.tmpdir(), "test-compare-1-" + Date.now()); + const testDir2 = path.join(os.tmpdir(), "test-compare-2-" + Date.now()); + + beforeEach(async () => { + await fs.mkdir(testDir1, { recursive: true }); + await fs.mkdir(testDir2, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir1, { recursive: true, force: true }); + await fs.rm(testDir2, { recursive: true, force: true }); + }); + + it("identifies files only in first directory", async () => { + await fs.writeFile(path.join(testDir1, "only1.txt"), "content1"); + await fs.writeFile(path.join(testDir2, "common.txt"), "common"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.onlyInDir1).toContain("only1.txt"); + expect(result.onlyInDir1).not.toContain("common.txt"); + expect(result.identical).toContain("common.txt"); + }); + + it("identifies files only in second directory", async () => { + await fs.writeFile(path.join(testDir1, "common.txt"), "common"); + await fs.writeFile(path.join(testDir2, "only2.txt"), "content2"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.onlyInDir2).toContain("only2.txt"); + expect(result.onlyInDir2).not.toContain("common.txt"); + }); + + it("detects files with different content", async () => { + await fs.writeFile(path.join(testDir1, "diff.txt"), "content1"); + await fs.writeFile(path.join(testDir2, "diff.txt"), "different content"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.differentContent).toHaveLength(1); + expect(result.differentContent[0].path).toBe("diff.txt"); + expect(result.differentContent[0].dir1Size).not.toBe(result.differentContent[0].dir2Size); + }); + + it("identifies identical files", async () => { + await fs.writeFile(path.join(testDir1, "same.txt"), "identical content"); + await fs.writeFile(path.join(testDir2, "same.txt"), "identical content"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.identical).toContain("same.txt"); + expect(result.differentContent).toHaveLength(0); + }); + + it("handles empty directories", async () => { + const result = await compareDirectories(testDir1, testDir2); + + expect(result.onlyInDir1).toHaveLength(0); + expect(result.onlyInDir2).toHaveLength(0); + expect(result.differentContent).toHaveLength(0); + expect(result.identical).toHaveLength(0); + }); + + it("compares nested directory structures", async () => { + await fs.mkdir(path.join(testDir1, "subdir"), { recursive: true }); + await fs.mkdir(path.join(testDir2, "subdir"), { recursive: true }); + await fs.writeFile(path.join(testDir1, "subdir", "nested.txt"), "nested"); + await fs.writeFile(path.join(testDir2, "subdir", "nested.txt"), "nested"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.identical).toContain(path.join("subdir", "nested.txt")); + }); +}); From f332c7c560b0fb03749d14c1f5041f5a63dd83b4 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 15:06:51 +0200 Subject: [PATCH 3/7] Fix fs mock issue for compare_directories tests --- src/filesystem/__tests__/lib.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index 8a8c039bc8..5d6019f4de 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -19,7 +19,9 @@ import { // File editing functions applyFileEdits, tailFile, - headFile + headFile, + // Directory comparison + compareDirectories } from '../lib.js'; // Mock fs module @@ -725,6 +727,9 @@ describe('Lib Functions', () => { }); describe("compareDirectories", () => { + // Unmock fs for integration tests + vi.unmock('fs/promises'); + const testDir1 = path.join(os.tmpdir(), "test-compare-1-" + Date.now()); const testDir2 = path.join(os.tmpdir(), "test-compare-2-" + Date.now()); From 7f5db95e28d7e005e6f5f5a23ace2c9c824dbcd9 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 15:41:59 +0200 Subject: [PATCH 4/7] Fix TypeScript errors: add compareDirectories import, fix searchFiles usage, add types --- src/filesystem/index.ts | 1 + src/filesystem/lib.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index efc9747d25..65f32f4fd4 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -26,6 +26,7 @@ import { tailFile, headFile, setAllowedDirectories, + compareDirectories, } from './lib.js'; // Command line argument parsing diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 158e68061e..6c4aa65723 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -432,11 +432,11 @@ export async function compareDirectories( dir2: string, compareContent: boolean = false ): Promise { - const dir1Files = await searchFiles(dir1, "**/*"); - const dir2Files = await searchFiles(dir2, "**/*"); + const dir1Files = await searchFilesWithValidation(dir1, "**/*"); + const dir2Files = await searchFilesWithValidation(dir2, "**/*"); - const dir1Set = new Set(dir1Files.map(f => path.relative(dir1, f))); - const dir2Set = new Set(dir2Files.map(f => path.relative(dir2, f))); + const dir1Set = new Set(dir1Files.map((f: string) => path.relative(dir1, f))); + const dir2Set = new Set(dir2Files.map((f: string) => path.relative(dir2, f))); const onlyInDir1: string[] = []; const onlyInDir2: string[] = []; From c9aa26ddcb2246d76f1b517f49172572aaf51f0d Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 16:56:19 +0200 Subject: [PATCH 5/7] Fix TypeScript errors: add missing allowedDirectories parameter to searchFilesWithValidation calls --- src/filesystem/lib.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 6c4aa65723..3113af87b5 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -432,8 +432,8 @@ export async function compareDirectories( dir2: string, compareContent: boolean = false ): Promise { - const dir1Files = await searchFilesWithValidation(dir1, "**/*"); - const dir2Files = await searchFilesWithValidation(dir2, "**/*"); + const dir1Files = await searchFilesWithValidation(dir1, "**/*", [dir1]); + const dir2Files = await searchFilesWithValidation(dir2, "**/*", [dir2]); const dir1Set = new Set(dir1Files.map((f: string) => path.relative(dir1, f))); const dir2Set = new Set(dir2Files.map((f: string) => path.relative(dir2, f))); From f6f1eb1b5808a701f03dbd5a347253b300f6ebb4 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 17:36:44 +0200 Subject: [PATCH 6/7] Fix test isolation: move compareDirectories tests to separate file --- .../__tests__/compare-directories.test.ts | 84 +++++++++++++++++++ src/filesystem/__tests__/lib.test.ts | 84 +------------------ 2 files changed, 85 insertions(+), 83 deletions(-) create mode 100644 src/filesystem/__tests__/compare-directories.test.ts diff --git a/src/filesystem/__tests__/compare-directories.test.ts b/src/filesystem/__tests__/compare-directories.test.ts new file mode 100644 index 0000000000..edb4955955 --- /dev/null +++ b/src/filesystem/__tests__/compare-directories.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { compareDirectories, setAllowedDirectories } from "../lib.js"; + +describe("compareDirectories", () => { + const testDir1 = path.join(os.tmpdir(), "test-compare-1-" + Date.now()); + const testDir2 = path.join(os.tmpdir(), "test-compare-2-" + Date.now()); + + beforeEach(async () => { + await fs.mkdir(testDir1, { recursive: true }); + await fs.mkdir(testDir2, { recursive: true }); + // Set allowed directories for validation + setAllowedDirectories([testDir1, testDir2]); + }); + + afterEach(async () => { + await fs.rm(testDir1, { recursive: true, force: true }); + await fs.rm(testDir2, { recursive: true, force: true }); + }); + + it("identifies files only in first directory", async () => { + await fs.writeFile(path.join(testDir1, "only1.txt"), "content1"); + await fs.writeFile(path.join(testDir2, "common.txt"), "common"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.onlyInDir1).toContain("only1.txt"); + expect(result.onlyInDir1).not.toContain("common.txt"); + expect(result.identical).toContain("common.txt"); + }); + + it("identifies files only in second directory", async () => { + await fs.writeFile(path.join(testDir1, "common.txt"), "common"); + await fs.writeFile(path.join(testDir2, "only2.txt"), "content2"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.onlyInDir2).toContain("only2.txt"); + expect(result.onlyInDir2).not.toContain("common.txt"); + }); + + it("detects files with different content", async () => { + await fs.writeFile(path.join(testDir1, "diff.txt"), "content1"); + await fs.writeFile(path.join(testDir2, "diff.txt"), "different content"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.differentContent).toHaveLength(1); + expect(result.differentContent[0].path).toBe("diff.txt"); + expect(result.differentContent[0].dir1Size).not.toBe(result.differentContent[0].dir2Size); + }); + + it("identifies identical files", async () => { + await fs.writeFile(path.join(testDir1, "same.txt"), "identical content"); + await fs.writeFile(path.join(testDir2, "same.txt"), "identical content"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.identical).toContain("same.txt"); + expect(result.differentContent).toHaveLength(0); + }); + + it("handles empty directories", async () => { + const result = await compareDirectories(testDir1, testDir2); + + expect(result.onlyInDir1).toHaveLength(0); + expect(result.onlyInDir2).toHaveLength(0); + expect(result.differentContent).toHaveLength(0); + expect(result.identical).toHaveLength(0); + }); + + it("compares nested directory structures", async () => { + await fs.mkdir(path.join(testDir1, "subdir"), { recursive: true }); + await fs.mkdir(path.join(testDir2, "subdir"), { recursive: true }); + await fs.writeFile(path.join(testDir1, "subdir", "nested.txt"), "nested"); + await fs.writeFile(path.join(testDir2, "subdir", "nested.txt"), "nested"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.identical).toContain(path.join("subdir", "nested.txt")); + }); +}); diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index 5d6019f4de..f7e585af22 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -19,9 +19,7 @@ import { // File editing functions applyFileEdits, tailFile, - headFile, - // Directory comparison - compareDirectories + headFile } from '../lib.js'; // Mock fs module @@ -725,83 +723,3 @@ describe('Lib Functions', () => { }); }); }); - -describe("compareDirectories", () => { - // Unmock fs for integration tests - vi.unmock('fs/promises'); - - const testDir1 = path.join(os.tmpdir(), "test-compare-1-" + Date.now()); - const testDir2 = path.join(os.tmpdir(), "test-compare-2-" + Date.now()); - - beforeEach(async () => { - await fs.mkdir(testDir1, { recursive: true }); - await fs.mkdir(testDir2, { recursive: true }); - }); - - afterEach(async () => { - await fs.rm(testDir1, { recursive: true, force: true }); - await fs.rm(testDir2, { recursive: true, force: true }); - }); - - it("identifies files only in first directory", async () => { - await fs.writeFile(path.join(testDir1, "only1.txt"), "content1"); - await fs.writeFile(path.join(testDir2, "common.txt"), "common"); - - const result = await compareDirectories(testDir1, testDir2); - - expect(result.onlyInDir1).toContain("only1.txt"); - expect(result.onlyInDir1).not.toContain("common.txt"); - expect(result.identical).toContain("common.txt"); - }); - - it("identifies files only in second directory", async () => { - await fs.writeFile(path.join(testDir1, "common.txt"), "common"); - await fs.writeFile(path.join(testDir2, "only2.txt"), "content2"); - - const result = await compareDirectories(testDir1, testDir2); - - expect(result.onlyInDir2).toContain("only2.txt"); - expect(result.onlyInDir2).not.toContain("common.txt"); - }); - - it("detects files with different content", async () => { - await fs.writeFile(path.join(testDir1, "diff.txt"), "content1"); - await fs.writeFile(path.join(testDir2, "diff.txt"), "different content"); - - const result = await compareDirectories(testDir1, testDir2); - - expect(result.differentContent).toHaveLength(1); - expect(result.differentContent[0].path).toBe("diff.txt"); - expect(result.differentContent[0].dir1Size).not.toBe(result.differentContent[0].dir2Size); - }); - - it("identifies identical files", async () => { - await fs.writeFile(path.join(testDir1, "same.txt"), "identical content"); - await fs.writeFile(path.join(testDir2, "same.txt"), "identical content"); - - const result = await compareDirectories(testDir1, testDir2); - - expect(result.identical).toContain("same.txt"); - expect(result.differentContent).toHaveLength(0); - }); - - it("handles empty directories", async () => { - const result = await compareDirectories(testDir1, testDir2); - - expect(result.onlyInDir1).toHaveLength(0); - expect(result.onlyInDir2).toHaveLength(0); - expect(result.differentContent).toHaveLength(0); - expect(result.identical).toHaveLength(0); - }); - - it("compares nested directory structures", async () => { - await fs.mkdir(path.join(testDir1, "subdir"), { recursive: true }); - await fs.mkdir(path.join(testDir2, "subdir"), { recursive: true }); - await fs.writeFile(path.join(testDir1, "subdir", "nested.txt"), "nested"); - await fs.writeFile(path.join(testDir2, "subdir", "nested.txt"), "nested"); - - const result = await compareDirectories(testDir1, testDir2); - - expect(result.identical).toContain(path.join("subdir", "nested.txt")); - }); -}); From c8944de825ba75fa8af0951cb14ede1212b605d1 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 19:28:18 +0200 Subject: [PATCH 7/7] Fix test logic: common.txt in both dirs, use forward slashes for nested paths --- src/filesystem/__tests__/compare-directories.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/filesystem/__tests__/compare-directories.test.ts b/src/filesystem/__tests__/compare-directories.test.ts index edb4955955..67dbb97c2a 100644 --- a/src/filesystem/__tests__/compare-directories.test.ts +++ b/src/filesystem/__tests__/compare-directories.test.ts @@ -22,6 +22,7 @@ describe("compareDirectories", () => { it("identifies files only in first directory", async () => { await fs.writeFile(path.join(testDir1, "only1.txt"), "content1"); + await fs.writeFile(path.join(testDir1, "common.txt"), "common"); await fs.writeFile(path.join(testDir2, "common.txt"), "common"); const result = await compareDirectories(testDir1, testDir2); @@ -79,6 +80,6 @@ describe("compareDirectories", () => { const result = await compareDirectories(testDir1, testDir2); - expect(result.identical).toContain(path.join("subdir", "nested.txt")); + expect(result.identical).toContain("subdir/nested.txt"); }); });