diff --git a/package-lock.json b/package-lock.json index 082300d3..c50a65d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "dependencies": { "@modelcontextprotocol/server-brave-search": "*", "@modelcontextprotocol/server-everything": "*", + "@modelcontextprotocol/server-filesystem": "*", "@modelcontextprotocol/server-gdrive": "*", "@modelcontextprotocol/server-memory": "*", "@modelcontextprotocol/server-postgres": "*", diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 82a43278..b4c4e92d 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -9,9 +9,90 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs/promises"; import path from "path"; +import os from 'os'; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; +// Command line argument parsing +const args = process.argv.slice(2); +if (args.length === 0) { + console.error("Usage: mcp-server-filesystem [additional-directories...]"); + process.exit(1); +} + +// Normalize all paths consistently +function normalizePath(p: string): string { + return path.normalize(p).toLowerCase(); +} + +function expandHome(filepath: string): string { + if (filepath.startsWith('~/') || filepath === '~') { + return path.join(os.homedir(), filepath.slice(1)); + } + return filepath; +} + +// Store allowed directories in normalized form +const allowedDirectories = args.map(dir => + normalizePath(path.resolve(expandHome(dir))) +); + +// Validate that all directories exist and are accessible +await Promise.all(args.map(async (dir) => { + try { + const stats = await fs.stat(dir); + if (!stats.isDirectory()) { + console.error(`Error: ${dir} is not a directory`); + process.exit(1); + } + } catch (error) { + console.error(`Error accessing directory ${dir}:`, error); + process.exit(1); + } +})); + +// Security utilities +async function validatePath(requestedPath: string): Promise { + const expandedPath = expandHome(requestedPath); + const absolute = path.isAbsolute(expandedPath) + ? path.resolve(expandedPath) + : path.resolve(process.cwd(), expandedPath); + + const normalizedRequested = normalizePath(absolute); + + // Check if path is within allowed directories + const isAllowed = allowedDirectories.some(dir => normalizedRequested.startsWith(dir)); + if (!isAllowed) { + throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`); + } + + // Handle symlinks by checking their real path + try { + const realPath = await fs.realpath(absolute); + const normalizedReal = normalizePath(realPath); + const isRealPathAllowed = allowedDirectories.some(dir => normalizedReal.startsWith(dir)); + if (!isRealPathAllowed) { + throw new Error("Access denied - symlink target outside allowed directories"); + } + return realPath; + } catch (error) { + // For new files that don't exist yet, verify parent directory + const parentDir = path.dirname(absolute); + try { + const realParentPath = await fs.realpath(parentDir); + const normalizedParent = normalizePath(realParentPath); + const isParentAllowed = allowedDirectories.some(dir => normalizedParent.startsWith(dir)); + if (!isParentAllowed) { + throw new Error("Access denied - parent directory outside allowed directories"); + } + return absolute; + } catch { + throw new Error(`Parent directory does not exist: ${parentDir}`); + } + } +} + +// Schema definitions const ReadFileArgsSchema = z.object({ path: z.string(), }); @@ -60,10 +141,11 @@ interface FileInfo { permissions: string; } +// Server setup const server = new Server( { - name: "example-servers/filesystem", - version: "0.1.0", + name: "secure-filesystem-server", + version: "0.2.0", }, { capabilities: { @@ -72,52 +154,7 @@ const server = new Server( }, ); -server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: "read_file", - description: "Read the complete contents of a file from the file system.", - inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput, - }, - { - name: "read_multiple_files", - description: "Read the contents of multiple files simultaneously.", - inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema) as ToolInput, - }, - { - name: "write_file", - description: "Create a new file or completely overwrite an existing file with new content.", - inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput, - }, - { - name: "create_directory", - description: "Create a new directory or ensure a directory exists.", - inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema) as ToolInput, - }, - { - name: "list_directory", - description: "Get a detailed listing of all files and directories in a specified path.", - inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput, - }, - { - name: "move_file", - description: "Move or rename files and directories.", - inputSchema: zodToJsonSchema(MoveFileArgsSchema) as ToolInput, - }, - { - name: "search_files", - description: "Recursively search for files and directories matching a pattern.", - inputSchema: zodToJsonSchema(SearchFilesArgsSchema) as ToolInput, - }, - { - name: "get_file_info", - description: "Retrieve detailed metadata about a file or directory.", - inputSchema: zodToJsonSchema(GetFileInfoArgsSchema) as ToolInput, - }, - ], - }; -}); +// Tool implementations async function getFileStats(filePath: string): Promise { const stats = await fs.stat(filePath); return { @@ -142,13 +179,21 @@ async function searchFiles( for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); + + try { + // Validate each path before processing + await validatePath(fullPath); - if (entry.name.toLowerCase().includes(pattern.toLowerCase())) { - results.push(fullPath); - } + if (entry.name.toLowerCase().includes(pattern.toLowerCase())) { + results.push(fullPath); + } - if (entry.isDirectory()) { - await search(fullPath); + if (entry.isDirectory()) { + await search(fullPath); + } + } catch (error) { + // Skip invalid paths during search + continue; } } } @@ -157,6 +202,99 @@ async function searchFiles( return results; } +// Tool handlers +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + 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) as ToolInput, + }, + { + 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) as ToolInput, + }, + { + 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) as ToolInput, + }, + { + 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) as ToolInput, + }, + { + 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) as ToolInput, + }, + { + 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) as ToolInput, + }, + { + 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) as ToolInput, + }, + { + 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) as ToolInput, + }, + { + 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: [], + }, + }, + ], + }; +}); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; @@ -167,7 +305,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!parsed.success) { throw new Error(`Invalid arguments for read_file: ${parsed.error}`); } - const content = await fs.readFile(parsed.data.path, "utf-8"); + const validPath = await validatePath(parsed.data.path); + const content = await fs.readFile(validPath, "utf-8"); return { content: [{ type: "text", text: content }], }; @@ -181,7 +320,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const results = await Promise.all( parsed.data.paths.map(async (filePath: string) => { try { - const content = await fs.readFile(filePath, "utf-8"); + const validPath = await validatePath(filePath); + const content = await fs.readFile(validPath, "utf-8"); return `${filePath}:\n${content}\n`; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -199,7 +339,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!parsed.success) { throw new Error(`Invalid arguments for write_file: ${parsed.error}`); } - await fs.writeFile(parsed.data.path, parsed.data.content, "utf-8"); + const validPath = await validatePath(parsed.data.path); + await fs.writeFile(validPath, parsed.data.content, "utf-8"); return { content: [{ type: "text", text: `Successfully wrote to ${parsed.data.path}` }], }; @@ -210,7 +351,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!parsed.success) { throw new Error(`Invalid arguments for create_directory: ${parsed.error}`); } - await fs.mkdir(parsed.data.path, { recursive: true }); + const validPath = await validatePath(parsed.data.path); + await fs.mkdir(validPath, { recursive: true }); return { content: [{ type: "text", text: `Successfully created directory ${parsed.data.path}` }], }; @@ -221,7 +363,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!parsed.success) { throw new Error(`Invalid arguments for list_directory: ${parsed.error}`); } - const entries = await fs.readdir(parsed.data.path, { withFileTypes: true }); + const validPath = await validatePath(parsed.data.path); + const entries = await fs.readdir(validPath, { withFileTypes: true }); const formatted = entries .map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`) .join("\n"); @@ -235,7 +378,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!parsed.success) { throw new Error(`Invalid arguments for move_file: ${parsed.error}`); } - await fs.rename(parsed.data.source, parsed.data.destination); + const validSourcePath = await validatePath(parsed.data.source); + const validDestPath = await validatePath(parsed.data.destination); + await fs.rename(validSourcePath, validDestPath); return { content: [{ type: "text", text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}` }], }; @@ -246,7 +391,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!parsed.success) { throw new Error(`Invalid arguments for search_files: ${parsed.error}`); } - const results = await searchFiles(parsed.data.path, parsed.data.pattern); + const validPath = await validatePath(parsed.data.path); + const results = await searchFiles(validPath, parsed.data.pattern); return { content: [{ type: "text", text: results.length > 0 ? results.join("\n") : "No matches found" }], }; @@ -257,7 +403,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!parsed.success) { throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`); } - const info = await getFileStats(parsed.data.path); + const validPath = await validatePath(parsed.data.path); + const info = await getFileStats(validPath); return { content: [{ type: "text", text: Object.entries(info) .map(([key, value]) => `${key}: ${value}`) @@ -265,6 +412,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case "list_allowed_directories": { + return { + content: [{ + type: "text", + text: `Allowed directories:\n${allowedDirectories.join('\n')}` + }], + }; + } + default: throw new Error(`Unknown tool: ${name}`); } @@ -277,13 +433,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } }); +// Start server async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); - console.error("MCP Server running on stdio"); + console.error("Secure MCP Filesystem Server running on stdio"); + console.error("Allowed directories:", allowedDirectories); } runServer().catch((error) => { console.error("Fatal error running server:", error); process.exit(1); -}); +}); \ No newline at end of file