Skip to main content
Glama

Knowledge Graph Memory Server

MIT License
52,555
68,825
  • Apple
  • Linux
index.ts27.3 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema, RootsListChangedNotificationSchema, type Root, } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs/promises"; import { createReadStream } from "fs"; import path from "path"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { minimatch } from "minimatch"; import { normalizePath, expandHome } from './path-utils.js'; import { getValidRootDirectories } from './roots-utils.js'; import { // Function imports formatSize, validatePath, getFileStats, readFileContent, writeFileContent, searchFilesWithValidation, applyFileEdits, tailFile, headFile, setAllowedDirectories, } from './lib.js'; // Command line argument parsing const args = process.argv.slice(2); if (args.length === 0) { console.error("Usage: mcp-server-filesystem [allowed-directory] [additional-directories...]"); console.error("Note: Allowed directories can be provided via:"); console.error(" 1. Command-line arguments (shown above)"); console.error(" 2. MCP roots protocol (if client supports it)"); console.error("At least one directory must be provided by EITHER method for the server to operate."); } // Store allowed directories in normalized and resolved form let allowedDirectories = await Promise.all( args.map(async (dir) => { const expanded = expandHome(dir); const absolute = path.resolve(expanded); try { // Security: Resolve symlinks in allowed directories during startup // This ensures we know the real paths and can validate against them later const resolved = await fs.realpath(absolute); return normalizePath(resolved); } catch (error) { // If we can't resolve (doesn't exist), use the normalized absolute path // This allows configuring allowed dirs that will be created later return normalizePath(absolute); } }) ); // Validate that all directories exist and are accessible await Promise.all(allowedDirectories.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); } })); // Initialize the global allowedDirectories in lib.ts 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 ToolInputSchema = ToolSchema.shape.inputSchema; type ToolInput = z.infer<typeof ToolInputSchema>; // Server setup const server = new Server( { name: "secure-filesystem-server", version: "0.2.0", }, { capabilities: { tools: {}, }, }, ); // 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<string> { 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 handlers server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "read_file", description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.", inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput, }, { name: "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: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput, }, { name: "read_media_file", description: "Read an image or audio file. Returns the base64 encoded data and MIME type. " + "Only works within allowed directories.", inputSchema: zodToJsonSchema(ReadMediaFileArgsSchema) 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: "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) 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: "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: zodToJsonSchema(ListDirectoryWithSizesArgsSchema) as ToolInput, }, { 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) 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. " + "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: 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. " + "Subdirectories within these allowed directories are also accessible. " + "Use this to understand which directories and their nested paths are available " + "before trying to access files.", inputSchema: { type: "object", properties: {}, required: [], }, }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; switch (name) { case "read_file": case "read_text_file": { const parsed = ReadTextFileArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for read_text_file: ${parsed.error}`); } const validPath = await validatePath(parsed.data.path); if (parsed.data.head && parsed.data.tail) { throw new Error("Cannot specify both head and tail parameters simultaneously"); } if (parsed.data.tail) { // Use memory-efficient tail implementation for large files const tailContent = await tailFile(validPath, parsed.data.tail); return { content: [{ type: "text", text: tailContent }], }; } if (parsed.data.head) { // Use memory-efficient head implementation for large files const headContent = await headFile(validPath, parsed.data.head); return { content: [{ type: "text", text: headContent }], }; } const content = await readFileContent(validPath); return { content: [{ type: "text", text: content }], }; } case "read_media_file": { const parsed = ReadMediaFileArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for read_media_file: ${parsed.error}`); } const validPath = await validatePath(parsed.data.path); const extension = path.extname(validPath).toLowerCase(); const mimeTypes: Record<string, string> = { ".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" : "blob"; return { content: [{ type, data, mimeType }], }; } case "read_multiple_files": { const parsed = ReadMultipleFilesArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for read_multiple_files: ${parsed.error}`); } const results = await Promise.all( parsed.data.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}`; } }), ); return { content: [{ type: "text", text: results.join("\n---\n") }], }; } case "write_file": { const parsed = WriteFileArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for write_file: ${parsed.error}`); } const validPath = await validatePath(parsed.data.path); await writeFileContent(validPath, parsed.data.content); return { content: [{ type: "text", text: `Successfully wrote to ${parsed.data.path}` }], }; } case "edit_file": { const parsed = EditFileArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for edit_file: ${parsed.error}`); } const validPath = await validatePath(parsed.data.path); const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun); return { content: [{ type: "text", text: result }], }; } case "create_directory": { const parsed = CreateDirectoryArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for create_directory: ${parsed.error}`); } const validPath = await validatePath(parsed.data.path); await fs.mkdir(validPath, { recursive: true }); return { content: [{ type: "text", text: `Successfully created directory ${parsed.data.path}` }], }; } case "list_directory": { const parsed = ListDirectoryArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for list_directory: ${parsed.error}`); } 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"); return { content: [{ type: "text", text: formatted }], }; } case "list_directory_with_sizes": { const parsed = ListDirectoryWithSizesArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for list_directory_with_sizes: ${parsed.error}`); } const validPath = await validatePath(parsed.data.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 (parsed.data.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)}` ]; return { content: [{ type: "text", text: [...formattedEntries, ...summary].join("\n") }], }; } case "directory_tree": { const parsed = DirectoryTreeArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`); } interface TreeEntry { name: string; type: 'file' | 'directory'; children?: TreeEntry[]; } const rootPath = parsed.data.path; async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> { 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, parsed.data.excludePatterns); return { content: [{ type: "text", text: JSON.stringify(treeData, null, 2) }], }; } case "move_file": { const parsed = MoveFileArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for move_file: ${parsed.error}`); } 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}` }], }; } case "search_files": { const parsed = SearchFilesArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for search_files: ${parsed.error}`); } const validPath = await validatePath(parsed.data.path); const results = await searchFilesWithValidation(validPath, parsed.data.pattern, allowedDirectories, { excludePatterns: parsed.data.excludePatterns }); return { content: [{ type: "text", text: results.length > 0 ? results.join("\n") : "No matches found" }], }; } case "get_file_info": { const parsed = GetFileInfoArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`); } 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}`) .join("\n") }], }; } case "list_allowed_directories": { return { content: [{ type: "text", text: `Allowed directories:\n${allowedDirectories.join('\n')}` }], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Error: ${errorMessage}` }], isError: true, }; } }); // Updates allowed directories based on MCP client roots async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) { const validatedRootDirs = await getValidRootDirectories(requestedRoots); if (validatedRootDirs.length > 0) { allowedDirectories = [...validatedRootDirs]; setAllowedDirectories(allowedDirectories); // Update the global state in lib.ts console.error(`Updated allowed directories from MCP roots: ${validatedRootDirs.length} valid directories`); } else { console.error("No valid root directories provided by client"); } } // Handles dynamic roots updates during runtime, when client sends "roots/list_changed" notification, server fetches the updated roots and replaces all allowed directories with the new roots. server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { try { // Request the updated roots list from the client const response = await server.listRoots(); if (response && 'roots' in response) { await updateAllowedDirectoriesFromRoots(response.roots); } } catch (error) { console.error("Failed to request roots from client:", error instanceof Error ? error.message : String(error)); } }); // Handles post-initialization setup, specifically checking for and fetching MCP roots. server.oninitialized = async () => { const clientCapabilities = server.getClientCapabilities(); if (clientCapabilities?.roots) { try { const response = await server.listRoots(); if (response && 'roots' in response) { await updateAllowedDirectoriesFromRoots(response.roots); } else { console.error("Client returned no roots set, keeping current settings"); } } catch (error) { console.error("Failed to request initial roots from client:", error instanceof Error ? error.message : String(error)); } } else { if (allowedDirectories.length > 0) { console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories); }else{ throw new Error(`Server cannot operate: No allowed directories available. Server was started without command-line directories and client either does not support MCP roots protocol or provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories.`); } } }; // Start server async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Secure MCP Filesystem Server running on stdio"); if (allowedDirectories.length === 0) { console.error("Started without allowed directories - waiting for client to provide roots via MCP protocol"); } } runServer().catch((error) => { console.error("Fatal error running server:", error); process.exit(1); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/modelcontextprotocol/knowledge-graph-memory-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server