Skip to main content
Glama

Cut-Copy-Paste Clipboard Server

file-handler.ts•13.5 kB
import { readFileSync, writeFileSync, existsSync, statSync, accessSync, constants, openSync, closeSync, readSync, } from 'fs'; import { resolve } from 'path'; import type { Stats } from 'fs'; import type { PathAccessControl } from './path-access-control.js'; export interface ReadLinesResult { content: string; lines: string[]; lineEnding: string; totalLines: number; } export interface FileSnapshot { filePath: string; content: string; lineCount: number; timestamp: number; } /** * Handles file operations with line-based manipulation * Supports reading, writing, inserting, and deleting lines from text files */ export class FileHandler { private readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB limit private readonly BINARY_CHECK_BYTES = 8000; // Check first 8KB for binary detection private pathAccessControl: PathAccessControl | null; constructor(pathAccessControl?: PathAccessControl) { this.pathAccessControl = pathAccessControl || null; } /** * Read a specific range of lines from a file (1-indexed) * @param filePath - Path to the file (absolute or relative) * @param startLine - Starting line number (1-indexed, inclusive) * @param endLine - Ending line number (1-indexed, inclusive) * @returns Object containing content, lines array, and metadata */ readLines(filePath: string, startLine: number, endLine: number): ReadLinesResult { // Resolve and validate inputs const resolvedPath = resolve(filePath); this.validatePathAccess(resolvedPath); this.validateFilePath(resolvedPath); if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) { throw new Error('Line numbers must be integers'); } if (startLine <= 0 || endLine <= 0) { throw new Error('Line numbers must be positive (1-indexed)'); } if (startLine > endLine) { throw new Error('Invalid line range: start line must be <= end line'); } const stats = this.ensureFileSizeWithinLimit(resolvedPath); // Check if file is binary if (!this.isTextFile(resolvedPath, stats)) { throw new Error('Binary files are not supported'); } // Read file content const content = readFileSync(resolvedPath, 'utf-8'); if (content.length === 0) { throw new Error('File is empty or line range exceeds file length'); } // Detect line ending const lineEnding = this.detectLineEnding(content); // Split into lines (preserving empty lines) const allLines = content.split(lineEnding); // Remove trailing empty line if file ends with newline if (allLines.length > 0 && allLines[allLines.length - 1] === '') { allLines.pop(); } const totalLines = allLines.length; // Validate line range if (endLine > totalLines) { throw new Error( `Line range exceeds file length (file has ${totalLines} lines, requested up to ${endLine})` ); } // Extract requested lines (convert from 1-indexed to 0-indexed) const requestedLines = allLines.slice(startLine - 1, endLine); return { content: requestedLines.join(lineEnding), lines: requestedLines, lineEnding, totalLines, }; } /** * Insert lines at a specified position (1-indexed) * @param filePath - Path to the file (absolute or relative) * @param insertAtLine - Line number to insert at (1-indexed) * @param lines - Array of lines to insert */ insertLines(filePath: string, insertAtLine: number, lines: string[]): void { const resolvedPath = resolve(filePath); this.validatePathAccess(resolvedPath); this.validateFilePath(resolvedPath); this.validateWriteAccess(resolvedPath); this.ensureFileSizeWithinLimit(resolvedPath); if (lines.length === 0) { return; // Nothing to insert } if (!Number.isInteger(insertAtLine)) { throw new Error('Insert position must be an integer (use 0 for file start)'); } // Read current content const content = readFileSync(resolvedPath, 'utf-8'); const lineEnding = this.detectLineEnding(content); // Split into lines const allLines = content.split(lineEnding); // Remove trailing empty line if present if (allLines.length > 0 && allLines[allLines.length - 1] === '') { allLines.pop(); } // Validate insert position (allow 0 for beginning of file) if (insertAtLine < 0 || insertAtLine > allLines.length + 1) { throw new Error( `Insert position exceeds file length (file has ${allLines.length} lines, insert position is ${insertAtLine})` ); } // Insert lines (handle line 0 as beginning of file) if (insertAtLine === 0) { allLines.splice(0, 0, ...lines); } else { // Convert from 1-indexed to 0-indexed allLines.splice(insertAtLine - 1, 0, ...lines); } // Write back to file const newContent = allLines.join(lineEnding) + (content.endsWith(lineEnding) ? lineEnding : ''); writeFileSync(resolvedPath, newContent, 'utf-8'); } /** * Delete a range of lines from a file (1-indexed) * @param filePath - Path to the file (absolute or relative) * @param startLine - Starting line number (1-indexed, inclusive) * @param endLine - Ending line number (1-indexed, inclusive) */ deleteLines(filePath: string, startLine: number, endLine: number): void { const resolvedPath = resolve(filePath); this.validatePathAccess(resolvedPath); this.validateFilePath(resolvedPath); this.validateWriteAccess(resolvedPath); this.ensureFileSizeWithinLimit(resolvedPath); if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) { throw new Error('Line numbers must be integers'); } if (startLine <= 0 || endLine <= 0) { throw new Error('Line numbers must be positive (1-indexed)'); } if (startLine > endLine) { throw new Error('Invalid line range: start line must be <= end line'); } // Read current content const content = readFileSync(resolvedPath, 'utf-8'); const lineEnding = this.detectLineEnding(content); // Split into lines const allLines = content.split(lineEnding); // Remove trailing empty line if present const hadTrailingNewline = allLines.length > 0 && allLines[allLines.length - 1] === ''; if (hadTrailingNewline) { allLines.pop(); } // Validate line range if (endLine > allLines.length) { throw new Error( `Line range exceeds file length (file has ${allLines.length} lines, requested up to ${endLine})` ); } // Delete lines (convert from 1-indexed to 0-indexed) allLines.splice(startLine - 1, endLine - startLine + 1); // Write back to file const newContent = allLines.length > 0 ? allLines.join(lineEnding) + (hadTrailingNewline ? lineEnding : '') : ''; writeFileSync(resolvedPath, newContent, 'utf-8'); } /** * Get a complete snapshot of a file * @param filePath - Path to the file (absolute or relative) * @returns File snapshot with content and metadata */ getFileSnapshot(filePath: string): FileSnapshot { const resolvedPath = resolve(filePath); this.validatePathAccess(resolvedPath); this.validateFilePath(resolvedPath); this.ensureFileSizeWithinLimit(resolvedPath); const content = readFileSync(resolvedPath, 'utf-8'); const lineEnding = this.detectLineEnding(content); const lines = content.split(lineEnding); // Remove trailing empty line if present if (lines.length > 0 && lines[lines.length - 1] === '') { lines.pop(); } return { filePath: resolve(filePath), content, lineCount: content.length === 0 ? 0 : lines.length, timestamp: Date.now(), }; } /** * Check if a file is a text file (not binary) * @param filePath - Path to the file * @returns true if the file appears to be text, false if binary */ isTextFile(filePath: string, stats?: Stats): boolean { if (!existsSync(filePath)) { return false; } const fileStats = stats ?? statSync(filePath); if (fileStats.size === 0) { return true; // Empty files are considered text } const checkLength = Math.min(fileStats.size, this.BINARY_CHECK_BYTES); const buffer = Buffer.alloc(checkLength); const fd = openSync(filePath, 'r'); try { readSync(fd, buffer, 0, checkLength, 0); } finally { closeSync(fd); } // Check for common binary file signatures if (this.hasBinarySignature(buffer)) { return false; } // Check for null bytes (strong indicator of binary file) for (let i = 0; i < checkLength; i++) { if (buffer[i] === 0) { return false; } } // Try to decode as UTF-8 to check if it's valid text try { const text = buffer.slice(0, checkLength).toString('utf-8'); // Check for replacement characters (indicates invalid UTF-8) // If we have replacement chars but they're not from the actual UTF-8 replacement sequence if (text.includes('\uFFFD')) { // Check if the buffer actually contains the UTF-8 replacement char bytes (EF BF BD) let hasActualReplacement = false; for (let i = 0; i < checkLength - 2; i++) { if (buffer[i] === 0xef && buffer[i + 1] === 0xbf && buffer[i + 2] === 0xbd) { hasActualReplacement = true; break; } } // If replacement char appears but isn't in original, it means invalid UTF-8 if (!hasActualReplacement) { return false; } } // Count control characters (excluding whitespace) let controlChars = 0; for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i); // Control characters except tab, newline, carriage return if (code < 32 && code !== 9 && code !== 10 && code !== 13) { controlChars++; } } // If more than 5% control characters, likely binary return controlChars / text.length < 0.05; } catch { // If UTF-8 decode fails, it's binary return false; } } /** * Ensure file size does not exceed maximum limit * @param filePath - Path to the file * @param stats - Optional stats object to reuse * @returns File stats */ private ensureFileSizeWithinLimit(filePath: string, stats?: Stats): Stats { const fileStats = stats ?? statSync(filePath); if (fileStats.size > this.MAX_FILE_SIZE) { throw new Error(`File size exceeds ${this.MAX_FILE_SIZE / 1024 / 1024}MB limit`); } return fileStats; } /** * Check for common binary file signatures * @param buffer - File buffer * @returns true if a binary signature is detected */ private hasBinarySignature(buffer: Buffer): boolean { // PNG signature: 89 50 4E 47 0D 0A 1A 0A if (buffer.length >= 8) { if ( buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 && buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a ) { return true; // PNG file } } // PDF signature: %PDF if (buffer.length >= 4) { const pdfSig = buffer.slice(0, 4).toString('ascii'); if (pdfSig === '%PDF') { return true; // PDF file } } // JPEG signature: FF D8 FF if (buffer.length >= 3) { if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { return true; // JPEG file } } // GIF signature: GIF87a or GIF89a if (buffer.length >= 6) { const gifSig = buffer.slice(0, 6).toString('ascii'); if (gifSig === 'GIF87a' || gifSig === 'GIF89a') { return true; // GIF file } } // ZIP/DOCX/JAR signature: PK if (buffer.length >= 2) { if (buffer[0] === 0x50 && buffer[1] === 0x4b) { return true; // ZIP-based file } } return false; } /** * Detect the line ending style used in a file * @param content - File content * @returns The detected line ending (\n, \r\n, or \n as default) */ private detectLineEnding(content: string): string { if (content.includes('\r\n')) { return '\r\n'; } if (content.includes('\n')) { return '\n'; } // Default to Unix line ending return '\n'; } /** * Validate that a path is allowed by the access control list * @param filePath - Path to validate (must be absolute) */ private validatePathAccess(filePath: string): void { if (this.pathAccessControl && !this.pathAccessControl.isPathAllowed(filePath)) { throw new Error(`Access denied: path not in allowlist: ${filePath}`); } } /** * Validate that a file path exists and is accessible * @param filePath - Path to validate */ private validateFilePath(filePath: string): void { if (!existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } const stats = statSync(filePath); if (!stats.isFile()) { throw new Error(`Path is not a file: ${filePath}`); } } /** * Validate that a file is writable * @param filePath - Path to validate */ private validateWriteAccess(filePath: string): void { try { accessSync(filePath, constants.W_OK); } catch { throw new Error(`Permission denied: cannot write to ${filePath}`); } } }

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/Pr0j3c7t0dd-Ltd/cut-copy-paste-mcp'

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