#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs/promises"; import { createReadStream } from "fs"; import http from "http"; import path from "path"; import { URL } from "url"; import { z } from "zod"; import { minimatch } from "minimatch"; import { // Function imports formatSize, validatePath, getFileStats, readFileContent, writeFileContent, searchFilesWithValidation, applyFileEdits, tailFile, headFile, setAllowedDirectories, } from './lib.js'; // Always use full filesystem (typical for container deployment) const allowedDirectories: string[] = ["/"]; setAllowedDirectories(allowedDirectories); // Schema definitions const ReadTextFileArgsSchema = z.object({ path: z.string(), tail: z.number().optional().describe('If provided, returns only the last N lines of the file'), head: z.number().optional().describe('If provided, returns only the first N lines of the file') }); const ReadMediaFileArgsSchema = z.object({ path: z.string() }); const ReadMultipleFilesArgsSchema = z.object({ paths: z .array(z.string()) .min(1, "At least one file path must be provided") .describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories."), }); const WriteFileArgsSchema = z.object({ path: z.string(), content: z.string(), }); const EditOperation = z.object({ oldText: z.string().describe('Text to search for - must match exactly'), newText: z.string().describe('Text to replace with') }); const EditFileArgsSchema = z.object({ path: z.string(), edits: z.array(EditOperation), dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') }); const CreateDirectoryArgsSchema = z.object({ path: z.string(), }); const ListDirectoryArgsSchema = z.object({ path: z.string(), }); const ListDirectoryWithSizesArgsSchema = z.object({ path: z.string(), sortBy: z.enum(['name', 'size']).optional().default('name').describe('Sort entries by name or size'), }); const DirectoryTreeArgsSchema = z.object({ path: z.string(), excludePatterns: z.array(z.string()).optional().default([]) }); const MoveFileArgsSchema = z.object({ source: z.string(), destination: z.string(), }); const SearchFilesArgsSchema = z.object({ path: z.string(), pattern: z.string(), excludePatterns: z.array(z.string()).optional().default([]) }); const GetFileInfoArgsSchema = z.object({ path: z.string(), }); // Server setup const server = new McpServer( { name: "secure-filesystem-server", version: "0.2.0", } ); // Reads a file as a stream of buffers, concatenates them, and then encodes // the result to a Base64 string. This is a memory-efficient way to handle // binary data from a stream before the final encoding. async function readFileAsBase64Stream(filePath: string): Promise { return new Promise((resolve, reject) => { const stream = createReadStream(filePath); const chunks: Buffer[] = []; stream.on('data', (chunk) => { chunks.push(chunk as Buffer); }); stream.on('end', () => { const finalBuffer = Buffer.concat(chunks); resolve(finalBuffer.toString('base64')); }); stream.on('error', (err) => reject(err)); }); } // Tool registrations // read_file (deprecated) and read_text_file const readTextFileHandler = async (args: z.infer) => { const validPath = await validatePath(args.path); if (args.head && args.tail) { throw new Error("Cannot specify both head and tail parameters simultaneously"); } let content: string; if (args.tail) { content = await tailFile(validPath, args.tail); } else if (args.head) { content = await headFile(validPath, args.head); } else { content = await readFileContent(validPath); } return { content: [{ type: "text" as const, text: content }], structuredContent: { content } }; }; server.registerTool( "read_file", { title: "Read File (Deprecated)", description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.", inputSchema: ReadTextFileArgsSchema.shape, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true } }, readTextFileHandler ); server.registerTool( "read_text_file", { title: "Read Text File", description: "Read the complete contents of a file from the file system as text. " + "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. Use the 'head' parameter to read only " + "the first N lines of a file, or the 'tail' parameter to read only " + "the last N lines of a file. Operates on the file as text regardless of extension. " + "Only works within allowed directories.", inputSchema: { path: z.string(), tail: z.number().optional().describe("If provided, returns only the last N lines of the file"), head: z.number().optional().describe("If provided, returns only the first N lines of the file") }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true } }, readTextFileHandler ); server.registerTool( "read_media_file", { title: "Read Media File", description: "Read an image or audio file. Returns the base64 encoded data and MIME type. " + "Only works within allowed directories.", inputSchema: { path: z.string() }, outputSchema: { content: z.array(z.object({ type: z.enum(["image", "audio", "blob"]), data: z.string(), mimeType: z.string() })) }, annotations: { readOnlyHint: true } }, async (args: z.infer) => { const validPath = await validatePath(args.path); const extension = path.extname(validPath).toLowerCase(); const mimeTypes: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".flac": "audio/flac", }; const mimeType = mimeTypes[extension] || "application/octet-stream"; const data = await readFileAsBase64Stream(validPath); const type = mimeType.startsWith("image/") ? "image" : mimeType.startsWith("audio/") ? "audio" // Fallback for other binary types, not officially supported by the spec but has been used for some time : "blob"; const contentItem = { type: type as 'image' | 'audio' | 'blob', data, mimeType }; return { content: [contentItem], structuredContent: { content: [contentItem] } } as unknown as CallToolResult; } ); server.registerTool( "read_multiple_files", { title: "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: { paths: z.array(z.string()) .min(1) .describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories.") }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true } }, async (args: z.infer) => { const results = await Promise.all( args.paths.map(async (filePath: string) => { try { const validPath = await validatePath(filePath); const content = await readFileContent(validPath); return `${filePath}:\n${content}\n`; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return `${filePath}: Error - ${errorMessage}`; } }), ); const text = results.join("\n---\n"); return { content: [{ type: "text" as const, text }], structuredContent: { content: text } }; } ); server.registerTool( "write_file", { title: "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: { path: z.string(), content: z.string() }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true } }, async (args: z.infer) => { const validPath = await validatePath(args.path); await writeFileContent(validPath, args.content); const text = `Successfully wrote to ${args.path}`; return { content: [{ type: "text" as const, text }], structuredContent: { content: text } }; } ); server.registerTool( "edit_file", { title: "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: { path: z.string(), edits: z.array(z.object({ oldText: z.string().describe("Text to search for - must match exactly"), newText: z.string().describe("Text to replace with") })), dryRun: z.boolean().default(false).describe("Preview changes using git-style diff format") }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true } }, async (args: z.infer) => { const validPath = await validatePath(args.path); const result = await applyFileEdits(validPath, args.edits, args.dryRun); return { content: [{ type: "text" as const, text: result }], structuredContent: { content: result } }; } ); server.registerTool( "create_directory", { title: "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: { path: z.string() }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false } }, async (args: z.infer) => { const validPath = await validatePath(args.path); await fs.mkdir(validPath, { recursive: true }); const text = `Successfully created directory ${args.path}`; return { content: [{ type: "text" as const, text }], structuredContent: { content: text } }; } ); server.registerTool( "list_directory", { title: "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: { path: z.string() }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true } }, async (args: z.infer) => { const validPath = await validatePath(args.path); const entries = await fs.readdir(validPath, { withFileTypes: true }); const formatted = entries .map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`) .join("\n"); return { content: [{ type: "text" as const, text: formatted }], structuredContent: { content: formatted } }; } ); server.registerTool( "list_directory_with_sizes", { title: "List Directory with Sizes", description: "Get a detailed listing of all files and directories in a specified path, including sizes. " + "Results clearly distinguish between files and directories with [FILE] and [DIR] " + "prefixes. This tool is useful for understanding directory structure and " + "finding specific files within a directory. Only works within allowed directories.", inputSchema: { path: z.string(), sortBy: z.enum(["name", "size"]).optional().default("name").describe("Sort entries by name or size") }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true } }, async (args: z.infer) => { const validPath = await validatePath(args.path); const entries = await fs.readdir(validPath, { withFileTypes: true }); // Get detailed information for each entry const detailedEntries = await Promise.all( entries.map(async (entry) => { const entryPath = path.join(validPath, entry.name); try { const stats = await fs.stat(entryPath); return { name: entry.name, isDirectory: entry.isDirectory(), size: stats.size, mtime: stats.mtime }; } catch (error) { return { name: entry.name, isDirectory: entry.isDirectory(), size: 0, mtime: new Date(0) }; } }) ); // Sort entries based on sortBy parameter const sortedEntries = [...detailedEntries].sort((a, b) => { if (args.sortBy === 'size') { return b.size - a.size; // Descending by size } // Default sort by name return a.name.localeCompare(b.name); }); // Format the output const formattedEntries = sortedEntries.map(entry => `${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${ entry.isDirectory ? "" : formatSize(entry.size).padStart(10) }` ); // Add summary const totalFiles = detailedEntries.filter(e => !e.isDirectory).length; const totalDirs = detailedEntries.filter(e => e.isDirectory).length; const totalSize = detailedEntries.reduce((sum, entry) => sum + (entry.isDirectory ? 0 : entry.size), 0); const summary = [ "", `Total: ${totalFiles} files, ${totalDirs} directories`, `Combined size: ${formatSize(totalSize)}` ]; const text = [...formattedEntries, ...summary].join("\n"); const contentBlock = { type: "text" as const, text }; return { content: [contentBlock], structuredContent: { content: text } }; } ); server.registerTool( "directory_tree", { title: "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: { path: z.string(), excludePatterns: z.array(z.string()).optional().default([]) }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true } }, async (args: z.infer) => { interface TreeEntry { name: string; type: 'file' | 'directory'; children?: TreeEntry[]; } const rootPath = args.path; async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise { const validPath = await validatePath(currentPath); const entries = await fs.readdir(validPath, { withFileTypes: true }); const result: TreeEntry[] = []; for (const entry of entries) { const relativePath = path.relative(rootPath, path.join(currentPath, entry.name)); const shouldExclude = excludePatterns.some(pattern => { if (pattern.includes('*')) { return minimatch(relativePath, pattern, { dot: true }); } // For files: match exact name or as part of path // For directories: match as directory path return minimatch(relativePath, pattern, { dot: true }) || minimatch(relativePath, `**/${pattern}`, { dot: true }) || minimatch(relativePath, `**/${pattern}/**`, { dot: true }); }); if (shouldExclude) continue; const entryData: TreeEntry = { name: entry.name, type: entry.isDirectory() ? 'directory' : 'file' }; if (entry.isDirectory()) { const subPath = path.join(currentPath, entry.name); entryData.children = await buildTree(subPath, excludePatterns); } result.push(entryData); } return result; } const treeData = await buildTree(rootPath, args.excludePatterns); const text = JSON.stringify(treeData, null, 2); const contentBlock = { type: "text" as const, text }; return { content: [contentBlock], structuredContent: { content: text } }; } ); server.registerTool( "move_file", { title: "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: { source: z.string(), destination: z.string() }, 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); await fs.rename(validSourcePath, validDestPath); const text = `Successfully moved ${args.source} to ${args.destination}`; const contentBlock = { type: "text" as const, text }; return { content: [contentBlock], structuredContent: { content: text } }; } ); server.registerTool( "search_files", { title: "Search Files", description: "Recursively search for files and directories matching a pattern. " + "The patterns should be glob-style patterns that match paths relative to the working directory. " + "Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " + "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: { path: z.string(), pattern: z.string(), excludePatterns: z.array(z.string()).optional().default([]) }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true } }, async (args: z.infer) => { const validPath = await validatePath(args.path); const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, { excludePatterns: args.excludePatterns }); const text = results.length > 0 ? results.join("\n") : "No matches found"; return { content: [{ type: "text" as const, text }], structuredContent: { content: text } }; } ); server.registerTool( "get_file_info", { title: "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: { path: z.string() }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true } }, async (args: z.infer) => { const validPath = await validatePath(args.path); const info = await getFileStats(validPath); const text = Object.entries(info) .map(([key, value]) => `${key}: ${value}`) .join("\n"); return { content: [{ type: "text" as const, text }], structuredContent: { content: text } }; } ); // SSE transport session routing (sessionId -> transport) const sseTransportsBySessionId = new Map(); function runServer() { const port = Number(process.env.MCP_PORT ?? process.env.SSE_PORT ?? 3000); const httpServer = http.createServer(async (req, res) => { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); const pathname = url.pathname; if (req.method === "GET" && (pathname === "/sse" || pathname === "/")) { try { const transport = new SSEServerTransport("/messages", res); sseTransportsBySessionId.set(transport.sessionId, transport); transport.onclose = () => { sseTransportsBySessionId.delete(transport.sessionId); }; // SSE heartbeat to prevent client ReadTimeout during idle (e.g. while waiting for Ollama) const heartbeatIntervalMs = 15_000; const heartbeatInterval = setInterval(() => { try { if (!res.writableEnded) { res.write(': heartbeat\n\n'); } else { clearInterval(heartbeatInterval); } } catch { clearInterval(heartbeatInterval); } }, heartbeatIntervalMs); res.on('close', () => clearInterval(heartbeatInterval)); await server.connect(transport); console.error("Secure MCP Filesystem Server: new SSE client connected"); } catch (error) { console.error("SSE connection error:", error); if (!res.headersSent) { res.writeHead(500).end("Internal server error"); } } return; } if (req.method === "POST" && pathname === "/messages") { const sessionId = url.searchParams.get("sessionId"); if (!sessionId) { res.writeHead(400).end("Missing sessionId query parameter"); return; } const transport = sseTransportsBySessionId.get(sessionId); if (!transport) { res.writeHead(404).end("Unknown session"); return; } await transport.handlePostMessage(req, res); return; } res.writeHead(404).end("Not found"); }); httpServer.listen(port, () => { console.error(`Secure MCP Filesystem Server running on SSE at http://localhost:${port}`); console.error(" GET /sse – open SSE stream (then POST to /messages?sessionId=...)"); console.error(" POST /messages?sessionId= – send MCP messages"); console.error(" Allowed directory: / (full filesystem)"); }); } runServer();