@gabrielmaialva33/mcp-filesystem
Version:
MCP server for secure filesystem access
176 lines • 7.35 kB
JavaScript
import fs from 'node:fs/promises';
import path from 'node:path';
import { createTwoFilesPatch } from 'diff';
import { z } from 'zod';
import { logger } from '../logger/index.js';
import { metrics } from '../metrics/index.js';
import { validateFileSize, validatePath } from './path.js';
import { FileSizeError, InvalidArgumentsError, PathNotFoundError } from '../errors/index.js';
export const ReadFileArgsSchema = z.object({
path: z.string().describe('Path to the file to read'),
encoding: z
.enum(['utf-8', 'utf8', 'base64'])
.optional()
.default('utf-8')
.describe('File encoding'),
});
export async function readFile(args, config) {
const endMetric = metrics.startOperation('read_file');
try {
const validPath = await validatePath(args.path, config);
if (config.security.maxFileSize > 0) {
await validateFileSize(validPath, config.security.maxFileSize);
}
const content = await fs.readFile(validPath, args.encoding);
await logger.debug(`Successfully read file: ${validPath}`);
endMetric();
return content;
}
catch (error) {
metrics.recordError('read_file');
if (error.code === 'ENOENT') {
throw new PathNotFoundError(args.path);
}
throw error;
}
}
export const ReadMultipleFilesArgsSchema = z.object({
paths: z.array(z.string()).describe('List of file paths to read'),
encoding: z
.enum(['utf-8', 'utf8', 'base64'])
.optional()
.default('utf-8')
.describe('File encoding'),
});
export async function readMultipleFiles(args, config) {
const endMetric = metrics.startOperation('read_multiple_files');
const results = {};
await Promise.all(args.paths.map(async (filePath) => {
try {
const validPath = await validatePath(filePath, config);
if (config.security.maxFileSize > 0) {
await validateFileSize(validPath, config.security.maxFileSize);
}
const content = await fs.readFile(validPath, args.encoding);
results[filePath] = content;
}
catch (error) {
if (error instanceof Error) {
results[filePath] = error;
}
else {
results[filePath] = new Error(String(error));
}
}
}));
endMetric();
return results;
}
export const WriteFileArgsSchema = z.object({
path: z.string().describe('Path where to write the file'),
content: z.string().describe('Content to write to the file'),
encoding: z
.enum(['utf-8', 'utf8', 'base64'])
.optional()
.default('utf-8')
.describe('File encoding'),
});
export async function writeFile(args, config) {
const endMetric = metrics.startOperation('write_file');
try {
const validPath = await validatePath(args.path, config);
if (config.security.maxFileSize > 0) {
const contentSize = Buffer.byteLength(args.content, args.encoding);
if (contentSize > config.security.maxFileSize) {
metrics.recordError('write_file');
throw new FileSizeError(args.path, contentSize, config.security.maxFileSize);
}
}
const parentDir = path.dirname(validPath);
await fs.mkdir(parentDir, { recursive: true });
await fs.writeFile(validPath, args.content, args.encoding);
await logger.debug(`Successfully wrote to file: ${validPath}`);
endMetric();
return `Successfully wrote to ${args.path}`;
}
catch (error) {
metrics.recordError('write_file');
throw error;
}
}
export const EditOperation = z.object({
oldText: z.string().describe('Text to search for - must match exactly'),
newText: z.string().describe('Text to replace with'),
});
export const EditFileArgsSchema = z.object({
path: z.string().describe('Path to the file to edit'),
edits: z.array(EditOperation).describe('List of edit operations to perform'),
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format'),
});
export async function editFile(args, config) {
const endMetric = metrics.startOperation('edit_file');
try {
const validPath = await validatePath(args.path, config);
const content = await fs.readFile(validPath, 'utf-8');
let modifiedContent = content;
let appliedAnyEdit = false;
for (const edit of args.edits) {
const contentLines = modifiedContent.split('\n');
let matchFound = false;
const normalizedOld = edit.oldText.replace(/\r\n/g, '\n');
const normalizedNew = edit.newText.replace(/\r\n/g, '\n');
const oldLines = normalizedOld.split('\n');
if (oldLines.length === 0) {
throw new InvalidArgumentsError('edit_file', 'Edit operation contains empty oldText');
}
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
const potentialMatch = contentLines.slice(i, i + oldLines.length).join('\n');
if (potentialMatch === normalizedOld) {
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '';
const newLines = normalizedNew.split('\n').map((line, j) => {
if (j === 0)
return originalIndent + line.trimStart();
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || '';
const newIndent = line.match(/^\s*/)?.[0] || '';
if (oldIndent && newIndent) {
const relativeIndent = newIndent.length - oldIndent.length;
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart();
}
return line;
});
contentLines.splice(i, oldLines.length, ...newLines);
modifiedContent = contentLines.join('\n');
matchFound = true;
appliedAnyEdit = true;
break;
}
}
if (!matchFound) {
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
}
}
if (!appliedAnyEdit) {
return 'No changes made - all edit patterns were empty or not found';
}
const diff = createUnifiedDiff(content, modifiedContent, validPath);
if (!args.dryRun) {
await fs.writeFile(validPath, modifiedContent, 'utf-8');
await logger.debug(`Successfully edited file: ${validPath}`);
}
endMetric();
return diff;
}
catch (error) {
metrics.recordError('edit_file');
throw error;
}
}
function createUnifiedDiff(originalContent, modifiedContent, filePath) {
const diff = createTwoFilesPatch(filePath, filePath, originalContent, modifiedContent, 'Original', 'Modified');
let numBackticks = 3;
while (diff.includes('`'.repeat(numBackticks))) {
numBackticks++;
}
return `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
}
//# sourceMappingURL=tools.js.map