diff --git a/src/filesystem/README.md b/src/filesystem/README.md index e9ddc2b1e2..6049eab29e 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -6,7 +6,7 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio - Read/write files - Create/list/delete directories -- Move files/directories +- Move/copy files/directories - Search files - Get file metadata - Dynamic directory access control via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) @@ -135,6 +135,14 @@ The server's directory access control follows this flow: - `destination` (string) - Fails if destination exists +- **copy_file** + - Copy a file or directory to a new location + - Inputs: + - `source` (string): Source path of the file or directory to copy + - `destination` (string): Destination path for the copy + - Fails if destination exists + - For directories, copies recursively while preserving structure and timestamps + - **search_files** - Recursively search for files/directories that match or do not match patterns - Inputs: @@ -201,6 +209,7 @@ The mapping for filesystem tools is: | `write_file` | `false` | `true` | `true` | Overwrites existing files | | `edit_file` | `false` | `false` | `true` | Re‑applying edits can fail or double‑apply | | `move_file` | `false` | `false` | `false` | Move/rename only; repeat usually errors | +| `copy_file` | `false` | `false` | `false` | Copy operation; repeat fails if dest exists | > Note: `idempotentHint` and `destructiveHint` are meaningful only when `readOnlyHint` is `false`, as defined by the MCP spec. diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 48a599fae1..8da60b3c03 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -9,6 +9,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs/promises"; import { createReadStream } from "fs"; +import { randomBytes } from "crypto"; import path from "path"; import { z } from "zod"; import { minimatch } from "minimatch"; @@ -130,6 +131,11 @@ const MoveFileArgsSchema = z.object({ destination: z.string(), }); +const CopyFileArgsSchema = z.object({ + source: z.string().describe("Source path of the file or directory to copy"), + destination: z.string().describe("Destination path for the copy"), +}); + const SearchFilesArgsSchema = z.object({ path: z.string(), pattern: z.string(), @@ -604,6 +610,90 @@ server.registerTool( } ); +server.registerTool( + "copy_file", + { + title: "Copy File", + description: + "Copy a file or directory to a new location. " + + "Both source and destination must be within allowed directories. " + + "Fails if the destination already exists. " + + "For directories, copies recursively while preserving structure and timestamps.", + inputSchema: { + source: z.string().describe("Source path of the file or directory to copy"), + destination: z.string().describe("Destination path for the copy") + }, + outputSchema: { content: z.string() }, + annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: false } + }, + async (args: z.infer) => { + const validSourcePath = await validatePath(args.source); + const validDestPath = await validatePath(args.destination); + + // Check if source exists and get its stats + let sourceStats; + try { + sourceStats = await fs.stat(validSourcePath); + } catch (error) { + throw new Error(`Source does not exist: ${args.source}`); + } + + // Generate a temporary path for atomic operation + const tempPath = `${validDestPath}.${randomBytes(16).toString("hex")}.tmp`; + + try { + if (sourceStats.isDirectory()) { + await fs.cp(validSourcePath, tempPath, { + recursive: true, + dereference: false, + preserveTimestamps: true, + }); + } else { + await fs.copyFile(validSourcePath, tempPath); + } + + // Atomic rename + await fs.rename(tempPath, validDestPath); + + } catch (error) { + // Cleanup + try { + const tempStats = await fs.stat(tempPath).catch(() => null); + if (tempStats) { + if (tempStats.isDirectory()) { + await fs.rm(tempPath, { recursive: true, force: true }); + } else { + await fs.unlink(tempPath); + } + } + } catch { + // Ignore cleanup errors + } + + // Handle error cases + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "EEXIST") { + throw new Error(`Destination already exists: ${args.destination}`); + } + if (nodeError.code === "ENOENT") { + throw new Error(`Source does not exist: ${args.source}`); + } + if (nodeError.code === "EACCES") { + throw new Error(`Permission denied: ${args.source} or ${args.destination}`); + } + + throw error; + } + + const text = `Successfully copied ${args.source} to ${args.destination}`; + const contentBlock = { type: "text" as const, text }; + return { + content: [contentBlock], + structuredContent: { content: text } + }; + } +); + server.registerTool( "search_files", { diff --git a/src/filesystem/package.json b/src/filesystem/package.json index 51760f6a2d..7ce553d09b 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-filesystem", - "version": "0.6.3", + "version": "0.6.4", "description": "MCP server for filesystem access", "license": "MIT", "mcpName": "io.github.modelcontextprotocol/server-filesystem",