clipboard-tools.tsā¢14.6 kB
import { FileHandler } from '../lib/file-handler.js';
import { ClipboardManager } from '../lib/clipboard-manager.js';
import { OperationLogger } from '../lib/operation-logger.js';
import { SessionManager } from '../lib/session-manager.js';
import type { PathAccessControl } from '../lib/path-access-control.js';
/**
* Clipboard tools for MCP server
* Provides copy, cut, paste, undo, and history operations
*/
export class ClipboardTools {
constructor(
private fileHandler: FileHandler,
private clipboardManager: ClipboardManager,
private operationLogger: OperationLogger,
private sessionManager: SessionManager,
private pathAccessControl?: PathAccessControl
) {}
/**
* Copy lines from a file to clipboard
*/
async copyLines(
sessionId: string,
filePath: string,
startLine: number,
endLine: number
): Promise<{
success: boolean;
content: string;
lines: string[];
message: string;
}> {
try {
// Validate session
const session = this.sessionManager.getSession(sessionId);
if (!session) {
throw new Error('Invalid session');
}
const normalizedFilePath = this.requireFilePath(filePath);
this.validatePathAccess(normalizedFilePath);
const normalizedStartLine = this.requirePositiveLineNumber(startLine, 'start_line');
const normalizedEndLine = this.requirePositiveLineNumber(endLine, 'end_line');
if (normalizedStartLine > normalizedEndLine) {
throw new Error('Invalid line range: start line must be <= end line');
}
// Read lines from file
const result = this.fileHandler.readLines(
normalizedFilePath,
normalizedStartLine,
normalizedEndLine
);
// Store in clipboard
this.clipboardManager.setClipboard(sessionId, {
content: result.content,
sourceFile: normalizedFilePath,
startLine: normalizedStartLine,
endLine: normalizedEndLine,
operationType: 'copy',
});
// Log operation
this.operationLogger.logCopy(sessionId, {
sourceFile: normalizedFilePath,
startLine: normalizedStartLine,
endLine: normalizedEndLine,
content: result.content,
});
return {
success: true,
content: result.content,
lines: result.lines,
message: `Copied ${result.lines.length} line(s) from ${filePath}:${startLine}-${endLine}`,
};
} catch (error) {
throw new Error(`Copy failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Cut lines from a file to clipboard
*/
async cutLines(
sessionId: string,
filePath: string,
startLine: number,
endLine: number
): Promise<{
success: boolean;
content: string;
lines: string[];
message: string;
}> {
try {
// Validate session
const session = this.sessionManager.getSession(sessionId);
if (!session) {
throw new Error('Invalid session');
}
const normalizedFilePath = this.requireFilePath(filePath);
this.validatePathAccess(normalizedFilePath);
const normalizedStartLine = this.requirePositiveLineNumber(startLine, 'start_line');
const normalizedEndLine = this.requirePositiveLineNumber(endLine, 'end_line');
if (normalizedStartLine > normalizedEndLine) {
throw new Error('Invalid line range: start line must be <= end line');
}
// Capture original file content BEFORE any modifications
const originalFileSnapshot = this.fileHandler.getFileSnapshot(normalizedFilePath);
// Read lines to be cut
const result = this.fileHandler.readLines(
normalizedFilePath,
normalizedStartLine,
normalizedEndLine
);
// Store in clipboard with original file content for undo
this.clipboardManager.setClipboard(sessionId, {
content: result.content,
sourceFile: normalizedFilePath,
startLine: normalizedStartLine,
endLine: normalizedEndLine,
operationType: 'cut',
cutSourceOriginalContent: originalFileSnapshot.content, // Store ORIGINAL state
});
// Log operation before deleting
this.operationLogger.logCut(sessionId, {
sourceFile: normalizedFilePath,
startLine: normalizedStartLine,
endLine: normalizedEndLine,
content: result.content,
});
// Delete lines from source file
this.fileHandler.deleteLines(normalizedFilePath, normalizedStartLine, normalizedEndLine);
return {
success: true,
content: result.content,
lines: result.lines,
message: `Cut ${result.lines.length} line(s) from ${filePath}:${startLine}-${endLine}`,
};
} catch (error) {
throw new Error(`Cut failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Paste clipboard content to one or more locations
*/
async pasteLines(
sessionId: string,
targets: Array<{ file_path: string; target_line: number }>
): Promise<{
success: boolean;
pastedTo: Array<{ file: string; line: number }>;
message: string;
}> {
try {
// Validate session
const session = this.sessionManager.getSession(sessionId);
if (!session) {
throw new Error('Invalid session');
}
const normalizedTargets = this.normalizePasteTargets(targets);
// Get clipboard content
const clipboard = this.clipboardManager.getClipboard(sessionId);
if (!clipboard) {
throw new Error('Clipboard is empty');
}
// Validate path access for all targets
for (const target of normalizedTargets) {
this.validatePathAccess(target.filePath);
}
// Prepare paste targets with original content for undo
const pasteTargets = normalizedTargets.map((target) => {
try {
const snapshot = this.fileHandler.getFileSnapshot(target.filePath);
return {
filePath: target.filePath,
targetLine: target.targetLine,
originalContent: snapshot.content,
};
} catch (error) {
throw new Error(
`Failed to read ${target.filePath}: ${error instanceof Error ? error.message : String(error)}`
);
}
});
// Perform paste operations
const pastedTo: Array<{ file: string; line: number }> = [];
for (const target of normalizedTargets) {
try {
this.fileHandler.insertLines(
target.filePath,
target.targetLine,
clipboard.content.split('\n')
);
pastedTo.push({
file: target.filePath,
line: target.targetLine,
});
} catch (error) {
// If any paste fails, we should ideally rollback
throw new Error(
`Failed to paste to ${target.filePath}:${target.targetLine}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
// If this paste came from a cut operation, use the stored original content
let cutSourceFile: string | undefined;
let cutSourceContent: string | undefined;
if (clipboard.operationType === 'cut' && clipboard.cutSourceOriginalContent) {
cutSourceFile = clipboard.sourceFile;
cutSourceContent = clipboard.cutSourceOriginalContent;
}
// Log paste operation with history for undo
this.operationLogger.logPaste(sessionId, {
targets: pasteTargets,
content: clipboard.content,
cutSourceFile,
cutSourceContent,
});
return {
success: true,
pastedTo,
message: `Pasted to ${pastedTo.length} location(s)`,
};
} catch (error) {
throw new Error(`Paste failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Show current clipboard content
*/
async showClipboard(sessionId: string): Promise<{
hasContent: boolean;
content?: string;
sourceFile?: string;
startLine?: number;
endLine?: number;
operationType?: 'copy' | 'cut';
copiedAt?: number;
}> {
try {
// Validate session
const session = this.sessionManager.getSession(sessionId);
if (!session) {
throw new Error('Invalid session');
}
const clipboard = this.clipboardManager.getClipboard(sessionId);
if (!clipboard) {
return {
hasContent: false,
};
}
return {
hasContent: true,
content: clipboard.content,
sourceFile: clipboard.sourceFile,
startLine: clipboard.startLine,
endLine: clipboard.endLine,
operationType: clipboard.operationType,
copiedAt: clipboard.copiedAt,
};
} catch (error) {
throw new Error(
`Failed to show clipboard: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Undo the last paste operation
*/
async undoLastPaste(sessionId: string): Promise<{
success: boolean;
restoredFiles: Array<{ file: string; line: number }>;
message: string;
}> {
try {
// Validate session
const session = this.sessionManager.getSession(sessionId);
if (!session) {
throw new Error('Invalid session');
}
// Get last paste operation
const lastPaste = this.operationLogger.getLastPaste(sessionId);
if (!lastPaste) {
throw new Error('No paste operation to undo');
}
// Restore original content for each target
const restoredFiles: Array<{ file: string; line: number }> = [];
for (const target of lastPaste.targets) {
try {
// Simply overwrite the entire file with original content
// This is the most reliable way to restore the exact state
const fs = await import('fs');
fs.writeFileSync(target.filePath, target.originalContent, 'utf-8');
restoredFiles.push({
file: target.filePath,
line: target.targetLine,
});
} catch (error) {
throw new Error(
`Failed to restore ${target.filePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
// If this paste came from a cut operation, restore the source file
if (lastPaste.cutSourceFile && lastPaste.cutSourceContent) {
try {
const fs = await import('fs');
fs.writeFileSync(lastPaste.cutSourceFile, lastPaste.cutSourceContent, 'utf-8');
restoredFiles.push({
file: lastPaste.cutSourceFile,
line: 0, // Source file restored entirely, not a specific line
});
} catch (error) {
throw new Error(
`Failed to restore cut source file ${lastPaste.cutSourceFile}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
// Log undo operation
this.operationLogger.logUndo(sessionId, lastPaste.operationId);
return {
success: true,
restoredFiles,
message: `Undone paste operation, restored ${restoredFiles.length} file(s)`,
};
} catch (error) {
throw new Error(`Undo failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get operation history for a session
*/
async getOperationHistory(
sessionId: string,
limit: number = 10
): Promise<{
operations: Array<{
operationId: number;
operationType: string;
timestamp: number;
details: {
sourceFile?: string;
startLine?: number;
endLine?: number;
targetFile?: string;
targetLine?: number;
};
}>;
}> {
try {
// Validate session
const session = this.sessionManager.getSession(sessionId);
if (!session) {
throw new Error('Invalid session');
}
const limitToUse = limit === undefined ? 10 : this.requirePositiveLineNumber(limit, 'limit');
const history = this.operationLogger.getHistory(sessionId, limitToUse);
return {
operations: history.map((op) => ({
operationId: op.operationId,
operationType: op.operationType,
timestamp: op.timestamp,
details: {
sourceFile: op.sourceFile,
startLine: op.startLine,
endLine: op.endLine,
targetFile: op.targetFile,
targetLine: op.targetLine,
},
})),
};
} catch (error) {
throw new Error(
`Failed to get history: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private validatePathAccess(filePath: string): void {
if (this.pathAccessControl && !this.pathAccessControl.isPathAllowed(filePath)) {
throw new Error(`Access denied: path not in allowlist: ${filePath}`);
}
}
private requireFilePath(filePath: unknown, field: string = 'file_path'): string {
if (typeof filePath !== 'string' || filePath.trim() === '') {
throw new Error(`${field} must be a non-empty string`);
}
return filePath;
}
private requirePositiveLineNumber(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
throw new Error(`${field} must be a positive integer`);
}
if (value <= 0) {
throw new Error(`${field} must be a positive integer`);
}
return value;
}
private requireNonNegativeLineNumber(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
throw new Error(`${field} must be an integer >= 0`);
}
if (value < 0) {
throw new Error(`${field} must be an integer >= 0`);
}
return value;
}
private normalizePasteTargets(
targets: Array<{ file_path: string; target_line: number }>
): Array<{ filePath: string; targetLine: number }> {
if (!Array.isArray(targets) || targets.length === 0) {
throw new Error('targets must be a non-empty array');
}
return targets.map((target, index) => {
const filePath = this.requireFilePath(target?.file_path, `targets[${index}].file_path`);
const targetLine = this.requireNonNegativeLineNumber(
target?.target_line,
`targets[${index}].target_line`
);
return {
filePath,
targetLine,
};
});
}
}