Skip to main content
Glama
fileService.ts•6.78 kB
import { promises as fs } from 'fs'; import path from 'path'; import { McpError, ErrorCode, TextContent } from "@modelcontextprotocol/sdk/types.js"; import { sanitizeAndValidatePath, isPathWithinBase } from '../utils/security.js'; import { ErrorHandler } from '../utils/errorHandler.js'; import { CacheEntry } from '../types.js'; /** * Secure file service with path validation, caching, and proper error handling */ export class SecureFileService { private cache = new Map<string, CacheEntry>(); private readonly maxFileSize: number; private readonly cacheTimeout: number; constructor(maxFileSize: number = 1024 * 1024, cacheTimeout: number = 5 * 60 * 1000) { this.maxFileSize = maxFileSize; this.cacheTimeout = cacheTimeout; } /** * Securely reads a file with path validation and caching * @param basePath - The base directory that should contain the file * @param userPath - The user-provided path component * @param extension - The expected file extension * @returns Promise resolving to file content */ async readSecureFile(basePath: string, userPath: string, extension: string): Promise<string> { // Validate and sanitize the user input const validation = sanitizeAndValidatePath(userPath); if (!validation.isValid) { throw new McpError(ErrorCode.InvalidParams, validation.error!); } const sanitizedPath = validation.sanitizedValue!; const fullPath = path.resolve(path.join(basePath, `${sanitizedPath}${extension}`)); // Verify the resolved path is within the allowed base directory if (!isPathWithinBase(fullPath, basePath)) { throw new McpError(ErrorCode.InvalidParams, "Invalid file path"); } // Check cache first const cached = this.getCachedContent(fullPath); if (cached) { return cached; } try { // Check if file exists and get stats const stats = await fs.stat(fullPath); // Validate file size ErrorHandler.validateFileSize(fullPath, stats, this.maxFileSize); // Read file content const content = await fs.readFile(fullPath, 'utf-8'); // Cache the content this.setCacheContent(fullPath, content, stats.mtime.getTime()); return content; } catch (error) { ErrorHandler.handleFileSystemError(error, 'readSecureFile', 'file reading'); } } /** * Securely lists directory contents with validation * @param dirPath - Directory path to list * @param fileExtension - Optional file extension filter * @returns Promise resolving to directory contents */ async listDirectoryContents(dirPath: string, fileExtension?: string): Promise<{ content: Array<{ type: "text"; text: string }> }> { try { const files = await fs.readdir(dirPath, { withFileTypes: true }); let contentList: string[]; if (fileExtension) { contentList = files .filter((dirent) => dirent.isFile() && dirent.name.endsWith(fileExtension)) .map((dirent) => dirent.name.replace(fileExtension, '')); } else { contentList = files .filter((dirent) => dirent.isDirectory()) .map((dirent) => dirent.name); } return { content: [{ type: "text" as const, text: contentList.join('\n') }] }; } catch (error) { // For directory listing, return empty result instead of throwing const emptyResult = ErrorHandler.handleDirectoryListingError(error, dirPath); return { content: [emptyResult] }; } } /** * Gets cached content if valid and not expired * @param filePath - Path to the file * @returns Cached content or null if not available/expired */ private getCachedContent(filePath: string): string | null { const cached = this.cache.get(filePath); if (!cached) { return null; } const now = Date.now(); if (now - cached.lastModified > this.cacheTimeout) { this.cache.delete(filePath); return null; } return cached.content; } /** * Sets content in cache * @param filePath - Path to the file * @param content - File content * @param lastModified - Last modified timestamp */ private setCacheContent(filePath: string, content: string, lastModified: number): void { this.cache.set(filePath, { content, lastModified }); } /** * Clears expired cache entries */ public clearExpiredCache(): void { const now = Date.now(); for (const [filePath, entry] of this.cache.entries()) { if (now - entry.lastModified > this.cacheTimeout) { this.cache.delete(filePath); } } } /** * Reads a complete documentation file (for full docs that are single files) * @param filePath - Full path to the documentation file * @param maxSize - Optional maximum file size (defaults to this.maxFileSize) * @returns Promise resolving to file content */ async readFullDocsFile(filePath: string, maxSize?: number): Promise<string> { // Check cache first const cached = this.getCachedContent(filePath); if (cached) { return cached; } try { // Check if file exists and get stats const stats = await fs.stat(filePath); // Validate file size - use provided maxSize or default to this.maxFileSize ErrorHandler.validateFileSize(filePath, stats, maxSize || this.maxFileSize); // Read file content const content = await fs.readFile(filePath, 'utf-8'); // Cache the content this.setCacheContent(filePath, content, stats.mtime.getTime()); return content; } catch (error) { ErrorHandler.handleFileSystemError(error, 'readFullDocsFile', 'full documentation file reading'); } } /** * Searches within documentation content * @param content - The documentation content to search * @param query - The search query * @param limit - Maximum number of results to return * @returns Array of matching context snippets */ searchDocsContent(content: string, query: string, limit: number = 5): string[] { const lines = content.split('\n'); const results: string[] = []; const searchLower = query.toLowerCase(); for (let i = 0; i < lines.length && results.length < limit; i++) { const line = lines[i]; if (line.toLowerCase().includes(searchLower)) { // Get context: 2 lines before and 2 lines after const contextStart = Math.max(0, i - 2); const contextEnd = Math.min(lines.length, i + 3); const context = lines.slice(contextStart, contextEnd).join('\n'); results.push(`--- Match ${results.length + 1} (line ${i + 1}) ---\n${context}\n`); } } if (results.length === 0) { return [`No matches found for "${query}"`]; } return results; } }

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/CaullenOmdahl/Nextjs-React-Tailwind-Assistant'

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