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;
}
}