#!/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 { spawn } from "child_process"; 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(), }); const RunCommandArgsSchema = z.object({ command: z.string().min(1), cwd: z.string().optional().describe("Working directory to run the command in. Must be within allowed directories."), timeoutMs: z.number().int().positive().optional().default(600_000).describe("Kill the command if it runs longer than this (ms). Default: 10 minutes."), env: z.record(z.string(), z.string()).optional().default({}).describe("Extra environment variables (string values)."), shell: z.enum(["bash", "sh"]).optional().default("bash").describe("Shell to use for the command. Default: bash."), maxOutputChars: z.number().int().positive().optional().default(200_000).describe("Maximum characters captured for each of stdout/stderr."), }); // 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)); }); } function appendWithLimit(current: string, chunk: string, maxChars: number): { next: string; truncated: boolean } { if (maxChars <= 0) return { next: "", truncated: true }; if (current.length >= maxChars) return { next: current, truncated: true }; const remaining = maxChars - current.length; if (chunk.length <= remaining) return { next: current + chunk, truncated: false }; return { next: current + chunk.slice(0, remaining), truncated: true }; } async function runShellCommand(args: z.infer): Promise<{ exitCode: number | null; signal: string | null; stdout: string; stderr: string; stdoutTruncated: boolean; stderrTruncated: boolean; timedOut: boolean; effectiveCwd: string; }> { const defaultCwd = process.env.MCP_DEFAULT_CWD ?? "/workspace"; const requestedCwd = args.cwd ?? defaultCwd; const effectiveCwd = await validatePath(requestedCwd); const cwdStats = await fs.stat(effectiveCwd); if (!cwdStats.isDirectory()) { throw new Error(`cwd is not a directory: ${requestedCwd}`); } const shellPath = args.shell === "sh" ? "/bin/sh" : "/bin/bash"; const env: NodeJS.ProcessEnv = { ...process.env, ...args.env }; return await new Promise((resolve) => { const child = spawn(shellPath, ["-lc", args.command], { cwd: effectiveCwd, env, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; let stdoutTruncated = false; let stderrTruncated = false; let timedOut = false; const killTimer = setTimeout(() => { timedOut = true; try { child.kill("SIGKILL"); } catch { // ignore } }, args.timeoutMs); child.stdout?.setEncoding("utf-8"); child.stderr?.setEncoding("utf-8"); child.stdout?.on("data", (data: string) => { const result = appendWithLimit(stdout, data, args.maxOutputChars); stdout = result.next; stdoutTruncated = stdoutTruncated || result.truncated; }); child.stderr?.on("data", (data: string) => { const result = appendWithLimit(stderr, data, args.maxOutputChars); stderr = result.next; stderrTruncated = stderrTruncated || result.truncated; }); child.on("close", (exitCode, signal) => { clearTimeout(killTimer); resolve({ exitCode, signal: signal ? String(signal) : null, stdout, stderr, stdoutTruncated, stderrTruncated, timedOut, effectiveCwd, }); }); }); } // 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 } }; } ); async function runCommandToolHandler(rawArgs: unknown) { const args = RunCommandArgsSchema.parse(rawArgs); const result = await runShellCommand(args); const textLines = [ `cwd: ${result.effectiveCwd}`, `exitCode: ${result.exitCode === null ? "null" : String(result.exitCode)}`, `signal: ${result.signal ?? "null"}`, `timedOut: ${result.timedOut ? "true" : "false"}`, `stdoutTruncated: ${result.stdoutTruncated ? "true" : "false"}`, `stderrTruncated: ${result.stderrTruncated ? "true" : "false"}`, "", "stdout:", result.stdout || "(empty)", "", "stderr:", result.stderr || "(empty)", ]; const text = textLines.join("\n"); return { content: [{ type: "text" as const, text }], structuredContent: { content: text, cwd: result.effectiveCwd, exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, stdout: result.stdout, stderr: result.stderr, stdoutTruncated: result.stdoutTruncated, stderrTruncated: result.stderrTruncated, }, }; } const runCommandToolDefinition = { title: "Run Command", description: "Execute a shell command inside this same container (same filesystem as the filesystem tools). " + "Uses a non-interactive shell (`bash -lc` by default). Returns stdout/stderr, exit code, and timeout info.", inputSchema: { command: z.string(), cwd: z.string().optional().describe("Working directory to run the command in. Must be within allowed directories."), timeoutMs: z.number().optional().describe("Kill the command if it runs longer than this (ms). Default: 10 minutes."), env: z.record(z.string(), z.string()).optional().describe("Extra environment variables (string values)."), shell: z.enum(["bash", "sh"]).optional().describe("Shell to use for the command. Default: bash."), maxOutputChars: z.number().optional().describe("Maximum characters captured for each of stdout/stderr."), }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true }, }; // Keep snake_case for consistency with existing filesystem tools, and also provide a camelCase alias. server.registerTool("run_command", runCommandToolDefinition, runCommandToolHandler); server.registerTool("runCommand", runCommandToolDefinition, runCommandToolHandler); // 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();