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
11 changes: 10 additions & 1 deletion src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
90 changes: 90 additions & 0 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<typeof CopyFileArgsSchema>) => {
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",
{
Expand Down
2 changes: 1 addition & 1 deletion src/filesystem/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down