@gabrielmaialva33/mcp-filesystem
Version:
MCP server for secure filesystem access
648 lines (645 loc) • 32.4 kB
JavaScript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema, } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import fs from 'node:fs/promises';
import path from 'node:path';
import { minimatch } from 'minimatch';
import { logger } from './logger/index.js';
import { createSampleConfig, loadConfig } from './config/index.js';
import { validatePath } from './utils/path.js';
import { editFile, EditFileArgsSchema, readFile, ReadFileArgsSchema, readMultipleFiles, ReadMultipleFilesArgsSchema, writeFile, WriteFileArgsSchema, } from './utils/tools.js';
import { FileSystemError } from './errors/index.js';
import { executeCommand, ExecuteCommandArgsSchema } from './utils/exec/index.js';
import { BashExecuteArgsSchema, BashPipeArgsSchema } from './utils/bash/bash_tools.js';
import { handleBashExecute, handleBashPipe } from './bash/tools/index.js';
import { CurlRequestArgsSchema, handleCurlRequest } from './utils/curl/index.js';
import { metrics } from './metrics/index.js';
const args = process.argv.slice(2);
let configPath;
if (args.includes('--help') || args.includes('-h')) {
console.log(`
MCP-Filesystem Server
Usage:
mcp-server-filesystem [options] <allowed-directory> [additional-directories...]
Options:
--help, -h Show this help message
--version, -v Show version information
--config=<path> Use configuration file at <path>
--create-config=<path> Create a sample configuration file at <path>
Examples:
mcp-server-filesystem /path/to/directory # Allow access to one directory
mcp-server-filesystem --config=/path/to/config.json # Use a config file
mcp-server-filesystem --create-config=config.json # Create a sample config
`);
process.exit(0);
}
if (args.includes('--version') || args.includes('-v')) {
console.log('MCP-Filesystem Server v0.3.0');
process.exit(0);
}
const createConfigArg = args.find((arg) => arg.startsWith('--create-config='));
if (createConfigArg) {
const configOutputPath = createConfigArg.split('=')[1];
if (!configOutputPath) {
console.error('Error: Missing path for --create-config');
process.exit(1);
}
createSampleConfig(configOutputPath)
.then(() => {
console.log(`Sample configuration created at: ${configOutputPath}`);
process.exit(0);
})
.catch((error) => {
console.error(`Error creating sample configuration: ${error}`);
process.exit(1);
});
}
else {
const configArg = args.find((arg) => arg.startsWith('--config='));
if (configArg) {
configPath = configArg.split('=')[1];
if (!configPath) {
console.error('Error: Missing path for --config');
process.exit(1);
}
}
}
const CreateDirectoryArgsSchema = z.object({
path: z.string().describe('Path of the directory to create'),
});
const ListDirectoryArgsSchema = z.object({
path: z.string().describe('Path of the directory to list'),
});
const DirectoryTreeArgsSchema = z.object({
path: z.string().describe('Path of the directory to create a tree view for'),
});
const MoveFileArgsSchema = z.object({
source: z.string().describe('Source path of the file or directory to move'),
destination: z.string().describe('Destination path where to move the file or directory'),
});
const SearchFilesArgsSchema = z.object({
path: z.string().describe('Root path to start searching from'),
pattern: z.string().describe('Pattern to match against filenames and directories'),
excludePatterns: z
.array(z.string())
.optional()
.default([])
.describe('Patterns to exclude from search results'),
});
const GetFileInfoArgsSchema = z.object({
path: z.string().describe('Path to the file or directory to get information about'),
});
const ToolInputSchema = ToolSchema.shape.inputSchema;
async function getFileStats(filePath) {
const stats = await fs.stat(filePath);
return {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
accessed: stats.atime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
permissions: stats.mode.toString(8).slice(-3),
};
}
async function runServer(config) {
logger.setLogLevel(config.logLevel);
if (config.logFile) {
logger.setLogFile(config.logFile);
}
await logger.info('Starting MCP-Filesystem server', {
version: config.serverVersion,
allowedDirectories: config.allowedDirectories,
});
await Promise.all(config.allowedDirectories.map(async (dir) => {
try {
const stats = await fs.stat(dir);
if (!stats.isDirectory()) {
await logger.error(`Error: ${dir} is not a directory`);
process.exit(1);
}
}
catch (error) {
await logger.error(`Error accessing directory ${dir}:`, { error });
process.exit(1);
}
}));
const server = new Server({
name: config.serverName,
version: config.serverVersion,
}, {
capabilities: {
tools: {},
},
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
const endMetric = metrics.startOperation('list_tools');
try {
await logger.debug('Handling ListTools request');
const result = {
tools: [
{
name: 'read_file',
description: 'Read the complete contents of a file from the file system. ' +
'Handles various text encodings and provides detailed error messages ' +
'if the file cannot be read. Use this tool when you need to examine ' +
'the contents of a single file. Only works within allowed directories.',
inputSchema: zodToJsonSchema(ReadFileArgsSchema),
},
{
name: 'read_multiple_files',
description: 'Read the contents of multiple files simultaneously. This is more ' +
'efficient than reading files one by one when you need to analyze ' +
"or compare multiple files. Each file's content is returned with its " +
"path as a reference. Failed reads for individual files won't stop " +
'the entire operation. Only works within allowed directories.',
inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema),
},
{
name: 'write_file',
description: 'Create a new file or completely overwrite an existing file with new content. ' +
'Use with caution as it will overwrite existing files without warning. ' +
'Handles text content with proper encoding. Only works within allowed directories.',
inputSchema: zodToJsonSchema(WriteFileArgsSchema),
},
{
name: 'edit_file',
description: 'Make line-based edits to a text file. Each edit replaces exact line sequences ' +
'with new content. Returns a git-style diff showing the changes made. ' +
'Only works within allowed directories.',
inputSchema: zodToJsonSchema(EditFileArgsSchema),
},
{
name: 'create_directory',
description: 'Create a new directory or ensure a directory exists. Can create multiple ' +
'nested directories in one operation. If the directory already exists, ' +
'this operation will succeed silently. Perfect for setting up directory ' +
'structures for projects or ensuring required paths exist. Only works within allowed directories.',
inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema),
},
{
name: 'list_directory',
description: 'Get a detailed listing of all files and directories in a specified path. ' +
'Results clearly distinguish between files and directories with [FILE] and [DIR] ' +
'prefixes. This tool is essential for understanding directory structure and ' +
'finding specific files within a directory. Only works within allowed directories.',
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema),
},
{
name: 'directory_tree',
description: 'Get a recursive tree view of files and directories as a JSON structure. ' +
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
'Files have no children array, while directories always have a children array (which may be empty). ' +
'The output is formatted with 2-space indentation for readability. Only works within allowed directories.',
inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema),
},
{
name: 'move_file',
description: 'Move or rename files and directories. Can move files between directories ' +
'and rename them in a single operation. If the destination exists, the ' +
'operation will fail. Works across different directories and can be used ' +
'for simple renaming within the same directory. Both source and destination must be within allowed directories.',
inputSchema: zodToJsonSchema(MoveFileArgsSchema),
},
{
name: 'search_files',
description: 'Recursively search for files and directories matching a pattern. ' +
'Searches through all subdirectories from the starting path. The search ' +
'is case-insensitive and matches partial names. Returns full paths to all ' +
"matching items. Great for finding files when you don't know their exact location. " +
'Only searches within allowed directories.',
inputSchema: zodToJsonSchema(SearchFilesArgsSchema),
},
{
name: 'get_file_info',
description: 'Retrieve detailed metadata about a file or directory. Returns comprehensive ' +
'information including size, creation time, last modified time, permissions, ' +
'and type. This tool is perfect for understanding file characteristics ' +
'without reading the actual content. Only works within allowed directories.',
inputSchema: zodToJsonSchema(GetFileInfoArgsSchema),
},
{
name: 'list_allowed_directories',
description: 'Returns the list of directories that this server is allowed to access. ' +
'Use this to understand which directories are available before trying to access files.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_metrics',
description: 'Returns performance metrics about server operations. ' +
'Useful for monitoring and debugging.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'execute_command',
description: 'Execute a system command with security restrictions. ' +
'Validates commands for safety and provides detailed output. ' +
'Limited to basic system operations with security checks.',
inputSchema: zodToJsonSchema(ExecuteCommandArgsSchema),
},
{
name: 'bash_execute',
description: 'Execute a Bash command directly with output capture. ' +
'More flexible than execute_command but still with security restrictions. ' +
'Allows for direct access to Bash functionality.',
inputSchema: zodToJsonSchema(BashExecuteArgsSchema),
},
{
name: 'bash_pipe',
description: 'Execute a sequence of Bash commands piped together. ' +
'Allows for powerful command combinations with pipes. ' +
'Results include both stdout and stderr.',
inputSchema: zodToJsonSchema(BashPipeArgsSchema),
},
{
name: 'curl_request',
description: 'Execute a curl request to an external HTTP API. ' +
'Allows specifying URL, method, headers, and data. ' +
'Useful for integrating with external services via HTTP.',
inputSchema: zodToJsonSchema(CurlRequestArgsSchema),
},
],
};
endMetric();
return result;
}
catch (error) {
metrics.recordError('list_tools');
await logger.error('Error in ListTools handler', { error });
throw error;
}
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: a } = request.params;
const endMetric = metrics.startOperation(name);
await logger.debug(`Handling tool call: ${name}`, { args: a });
try {
switch (name) {
case 'read_file': {
const parsed = ReadFileArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
const content = await readFile(parsed.data, config);
endMetric();
return {
content: [{ type: 'text', text: content }],
};
}
case 'read_multiple_files': {
const parsed = ReadMultipleFilesArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
const results = await readMultipleFiles(parsed.data, config);
const formattedResults = Object.entries(results)
.map(([filePath, content]) => {
if (content instanceof Error) {
return `${filePath}: Error - ${content.message}`;
}
return `${filePath}:\n${content}\n`;
})
.join('\n---\n');
endMetric();
return {
content: [{ type: 'text', text: formattedResults }],
};
}
case 'write_file': {
const parsed = WriteFileArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
const result = await writeFile(parsed.data, config);
endMetric();
return {
content: [{ type: 'text', text: result }],
};
}
case 'edit_file': {
const parsed = EditFileArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
const result = await editFile(parsed.data, config);
endMetric();
return {
content: [{ type: 'text', text: result }],
};
}
case 'create_directory': {
const parsed = CreateDirectoryArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
const validPath = await validatePath(parsed.data.path, config);
await fs.mkdir(validPath, { recursive: true });
await logger.debug(`Created directory: ${validPath}`);
endMetric();
return {
content: [{ type: 'text', text: `Successfully created directory ${parsed.data.path}` }],
};
}
case 'list_directory': {
const parsed = ListDirectoryArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
const validPath = await validatePath(parsed.data.path, config);
const entries = await fs.readdir(validPath, { withFileTypes: true });
entries.sort((c, d) => {
if (c.isDirectory() && !d.isDirectory())
return -1;
if (!c.isDirectory() && d.isDirectory())
return 1;
return c.name.localeCompare(d.name);
});
const formatted = entries
.map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
.join('\n');
await logger.debug(`Listed directory: ${validPath}`, { entryCount: entries.length });
endMetric();
return {
content: [{ type: 'text', text: formatted }],
};
}
case 'directory_tree': {
const parsed = DirectoryTreeArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
async function buildTree(currentPath) {
const validPath = await validatePath(currentPath, config);
const entries = await fs.readdir(validPath, { withFileTypes: true });
entries.sort((f, g) => {
if (f.isDirectory() && !g.isDirectory())
return -1;
if (!f.isDirectory() && g.isDirectory())
return 1;
return f.name.localeCompare(g.name);
});
const result = [];
for (const entry of entries) {
const entryData = {
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file',
};
if (entry.isDirectory()) {
try {
const subPath = path.join(currentPath, entry.name);
entryData.children = await buildTree(subPath);
}
catch (error) {
entryData.children = [];
}
}
result.push(entryData);
}
return result;
}
const treeData = await buildTree(parsed.data.path);
await logger.debug(`Generated directory tree: ${parsed.data.path}`);
endMetric();
return {
content: [
{
type: 'text',
text: JSON.stringify(treeData, null, 2),
},
],
};
}
case 'move_file': {
const parsed = MoveFileArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
const validSourcePath = await validatePath(parsed.data.source, config);
const validDestPath = await validatePath(parsed.data.destination, config);
const destDir = path.dirname(validDestPath);
await fs.mkdir(destDir, { recursive: true });
await fs.rename(validSourcePath, validDestPath);
await logger.debug(`Moved file from ${validSourcePath} to ${validDestPath}`);
endMetric();
return {
content: [
{
type: 'text',
text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}`,
},
],
};
}
case 'search_files': {
const parsed = SearchFilesArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
const validPath = await validatePath(parsed.data.path, config);
const patternLower = parsed.data.pattern.toLowerCase();
const results = [];
async function search(currentPath) {
try {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
try {
await validatePath(fullPath, config);
const relativePath = path.relative(validPath, fullPath);
const shouldExclude = parsed.data &&
parsed.data.excludePatterns.some((excludePattern) => {
const globPattern = excludePattern.includes('*')
? excludePattern
: `**/${excludePattern}**`;
return minimatch(relativePath, globPattern, { nocase: true });
});
if (shouldExclude) {
continue;
}
if (entry.name.toLowerCase().includes(patternLower)) {
results.push(fullPath);
}
if (entry.isDirectory()) {
await search(fullPath);
}
}
catch (error) {
continue;
}
}
}
catch (error) {
return;
}
}
await search(validPath);
await logger.debug(`Search complete: ${parsed.data.pattern}`, {
resultCount: results.length,
});
endMetric();
return {
content: [
{
type: 'text',
text: results.length > 0
? `Found ${results.length} matches:\n${results.join('\n')}`
: 'No matches found',
},
],
};
}
case 'get_file_info': {
const parsed = GetFileInfoArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
const validPath = await validatePath(parsed.data.path, config);
const info = await getFileStats(validPath);
await logger.debug(`Retrieved file info: ${validPath}`);
endMetric();
return {
content: [
{
type: 'text',
text: Object.entries(info)
.map(([key, value]) => `${key}: ${value}`)
.join('\n'),
},
],
};
}
case 'list_allowed_directories': {
await logger.debug('Listed allowed directories');
endMetric();
return {
content: [
{
type: 'text',
text: `Allowed directories:\n${config.allowedDirectories.join('\n')}`,
},
],
};
}
case 'get_metrics': {
const metricsData = metrics.getMetrics();
await logger.debug('Retrieved metrics');
endMetric();
return {
content: [
{
type: 'text',
text: JSON.stringify(metricsData, null, 2),
},
],
};
}
case 'execute_command': {
const parsed = ExecuteCommandArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
const result = await executeCommand(parsed.data, config);
endMetric();
return {
content: [
{
type: 'text',
text: `Command execution completed with exit code: ${result.exitCode}\n\nSTDOUT:\n${result.stdout}\n\nSTDERR:\n${result.stderr}`,
},
],
};
}
case 'bash_execute': {
return await handleBashExecute(a, config);
}
case 'bash_pipe': {
return await handleBashPipe(a, config);
}
case 'curl_request': {
const parsed = CurlRequestArgsSchema.safeParse(a);
if (!parsed.success) {
throw new FileSystemError(`Invalid arguments for ${name}`, 'INVALID_ARGS', undefined, {
errors: parsed.error.format(),
});
}
return await handleCurlRequest(parsed.data, config);
}
default:
throw new FileSystemError(`Unknown tool: ${name}`, 'UNKNOWN_TOOL');
}
}
catch (error) {
metrics.recordError(name);
if (error instanceof FileSystemError) {
await logger.error(`Error in ${name}:`, error instanceof FileSystemError ? error.toJSON() : { message: String(error) });
return {
content: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
const errorMessage = error instanceof Error ? error.message : String(error);
await logger.error(`Unexpected error in ${name}:`, { error });
return {
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
isError: true,
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
await logger.info('MCP-Filesystem Server running on stdio', {
allowedDirectories: config.allowedDirectories,
serverVersion: config.serverVersion,
});
if (config.metrics.enabled && config.metrics.reportIntervalMs > 0) {
setInterval(() => {
const metricsData = metrics.getMetrics();
logger.info('Performance metrics', { metrics: metricsData });
}, config.metrics.reportIntervalMs);
}
}
loadConfig(configPath)
.then(runServer)
.catch(async (error) => {
console.error('Fatal error loading configuration or running server:', error);
process.exit(1);
});
//# sourceMappingURL=index.js.map