Skip to main content
Glama

filesystem-mcp

by sylphxltd
move-items.ts11.7 kB
// src/handlers/moveItems.ts import fsPromises from 'node:fs/promises'; // Use default import import path from 'node:path'; import { z } from 'zod'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import * as pathUtils from '../utils/path-utils.js'; // Import namespace // --- Dependency Injection Interface --- interface MoveItemsDependencies { access: typeof fsPromises.access; rename: typeof fsPromises.rename; mkdir: typeof fsPromises.mkdir; resolvePath: typeof pathUtils.resolvePath; PROJECT_ROOT: string; } // --- Types --- import type { McpToolResponse } from '../types/mcp-types.js'; export const MoveOperationSchema = z .object({ source: z.string().describe('Relative path of the source.'), destination: z.string().describe('Relative path of the destination.'), }) .strict(); export const MoveItemsArgsSchema = z .object({ operations: z .array(MoveOperationSchema) .min(1, { message: 'Operations array cannot be empty' }) .describe('Array of {source, destination} objects.'), }) .strict(); type MoveItemsArgs = z.infer<typeof MoveItemsArgsSchema>; type MoveOperation = z.infer<typeof MoveOperationSchema>; interface MoveResult { source: string; destination: string; success: boolean; error?: string; } // --- Parameter Interfaces --- interface HandleMoveErrorParams { error: unknown; sourceRelative: string; destinationRelative: string; sourceOutput: string; destOutput: string; } interface ProcessSingleMoveParams { op: MoveOperation; } // --- Helper Functions --- /** Parses and validates the input arguments. */ function parseAndValidateArgs(args: unknown): MoveItemsArgs { try { return MoveItemsArgsSchema.parse(args); } catch (error) { if (error instanceof z.ZodError) { throw new McpError( ErrorCode.InvalidParams, `Invalid arguments: ${error.errors.map((e) => `${e.path.join('.')} (${e.message})`).join(', ')}`, ); } throw new McpError(ErrorCode.InvalidParams, 'Argument validation failed'); } } /** Handles errors during the move operation for a single item. */ function handleMoveError({ error, sourceRelative, destinationRelative, sourceOutput, destOutput, }: HandleMoveErrorParams): MoveResult { let errorMessage = 'An unknown error occurred during move/rename.'; let errorCode: string | undefined = undefined; if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') { errorCode = error.code; } if (error instanceof McpError) { errorMessage = error.message; // Preserve specific MCP errors (e.g., path resolution) } else if (error instanceof Error) { errorMessage = `Failed to move item: ${error.message}`; } // Handle specific filesystem error codes if (errorCode === 'ENOENT') { errorMessage = `Source path not found: ${sourceRelative}`; } else if (errorCode === 'EPERM' || errorCode === 'EACCES') { errorMessage = `Permission denied moving '${sourceRelative}' to '${destinationRelative}'.`; } // TODO: Consider handling EXDEV (cross-device link) // Error logged via McpError return { source: sourceOutput, destination: destOutput, success: false, error: errorMessage, }; } interface SourceCheckParams { sourceAbsolute: string; sourceRelative: string; sourceOutput: string; destOutput: string; } interface MoveOperationParams { sourceAbsolute: string; destinationAbsolute: string; sourceOutput: string; destOutput: string; } /** Validates move operation parameters. */ function validateMoveOperation(op: MoveOperation | undefined): MoveResult | undefined { if (!op || !op.source || !op.destination) { const sourceOutput = op?.source?.replaceAll('\\', '/') || 'undefined'; const destOutput = op?.destination?.replaceAll('\\', '/') || 'undefined'; return { source: sourceOutput, destination: destOutput, success: false, error: 'Invalid operation: source and destination must be defined.', }; } return undefined; } /** Handles special error cases for move operations. */ function handleSpecialMoveErrors( error: unknown, sourceOutput: string, destOutput: string, ): MoveResult | undefined { if (error instanceof McpError && error.message.includes('Absolute paths are not allowed')) { return { source: sourceOutput, destination: destOutput, success: false, error: error.message, }; } return undefined; } /** Processes a single move/rename operation. */ async function processSingleMoveOperation( params: ProcessSingleMoveParams, dependencies: MoveItemsDependencies, // Inject dependencies ): Promise<MoveResult> { const { op } = params; // Validate operation parameters const validationResult = validateMoveOperation(op); if (validationResult) return validationResult; const sourceRelative = op.source; const destinationRelative = op.destination; const sourceOutput = sourceRelative.replaceAll('\\', '/'); const destOutput = destinationRelative.replaceAll('\\', '/'); try { // Safely resolve paths using injected dependency const sourceAbsolute = dependencies.resolvePath(sourceRelative); const destinationAbsolute = dependencies.resolvePath(destinationRelative); if (sourceAbsolute === dependencies.PROJECT_ROOT) { // Use injected dependency return { source: sourceOutput, destination: destOutput, success: false, error: 'Moving the project root is not allowed.', }; } // Check source existence using injected dependency const sourceCheckResult = await checkSourceExists( { sourceAbsolute, sourceRelative, sourceOutput, destOutput, }, dependencies, // Pass dependencies ); // Ensure we return immediately if source check fails (No change needed here, already correct) if (sourceCheckResult) return sourceCheckResult; // Perform the move using injected dependency return await performMoveOperation( { sourceAbsolute, destinationAbsolute, sourceOutput, destOutput, }, dependencies, // Pass dependencies ); } catch (error) { const specialErrorResult = handleSpecialMoveErrors(error, sourceOutput, destOutput); if (specialErrorResult) return specialErrorResult; return handleMoveError({ error, sourceRelative, destinationRelative, sourceOutput, destOutput, }); } } /** Processes results from Promise.allSettled. */ function processSettledResults( results: PromiseSettledResult<MoveResult>[], originalOps: MoveOperation[], ): MoveResult[] { return results.map((result, index) => { const op = originalOps[index]; const sourceOutput = (op?.source ?? 'unknown').replaceAll('\\', '/'); const destOutput = (op?.destination ?? 'unknown').replaceAll('\\', '/'); return result.status === 'fulfilled' ? result.value : { source: sourceOutput, destination: destOutput, success: false, error: `Unexpected error during processing: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`, }; }); } /** Core logic function with dependency injection */ export const handleMoveItemsFuncCore = async ( args: unknown, dependencies: MoveItemsDependencies, ): Promise<McpToolResponse> => { const { operations } = parseAndValidateArgs(args); const movePromises = operations.map((op) => processSingleMoveOperation({ op }, dependencies), // Pass dependencies ); const settledResults = await Promise.allSettled(movePromises); const outputResults = processSettledResults(settledResults, operations); // Sort results based on the original order const originalIndexMap = new Map(operations.map((op, i) => [op.source.replaceAll('\\', '/'), i])); outputResults.sort((a, b) => { const indexA = originalIndexMap.get(a.source) ?? Infinity; const indexB = originalIndexMap.get(b.source) ?? Infinity; return indexA - indexB; }); return { content: [{ type: 'text', text: JSON.stringify(outputResults, undefined, 2) }], }; }; // --- Exported Handler (Wrapper) --- /** Main handler function (wraps core logic with actual dependencies) */ const handleMoveItemsFunc = async (args: unknown): Promise<McpToolResponse> => { const dependencies: MoveItemsDependencies = { access: fsPromises.access, rename: fsPromises.rename, mkdir: fsPromises.mkdir, resolvePath: pathUtils.resolvePath, PROJECT_ROOT: pathUtils.PROJECT_ROOT, }; return handleMoveItemsFuncCore(args, dependencies); }; // Export the complete tool definition using the wrapper handler export const moveItemsToolDefinition = { name: 'move_items', description: 'Move or rename multiple specified files/directories.', inputSchema: MoveItemsArgsSchema, handler: handleMoveItemsFunc, // Use the wrapper }; // --- Helper Functions Modified for DI --- /** Checks if source exists and is accessible. */ async function checkSourceExists( params: SourceCheckParams, dependencies: MoveItemsDependencies, // Inject dependencies ): Promise<MoveResult | undefined> { try { await dependencies.access(params.sourceAbsolute); // Use injected dependency return undefined; } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return { source: params.sourceOutput, destination: params.destOutput, success: false, error: `Source path not found: ${params.sourceRelative}`, }; } // Log other access errors for debugging, but rethrow to be caught by main handler console.error(`[Filesystem MCP - checkSourceExists] Unexpected access error for ${params.sourceRelative}:`, error); throw error; } } /** Performs the actual move operation. */ async function performMoveOperation( params: MoveOperationParams, dependencies: MoveItemsDependencies, // Inject dependencies ): Promise<MoveResult> { const destDir = path.dirname(params.destinationAbsolute); // Skip mkdir if: // 1. Destination is in root (destDir === PROJECT_ROOT) // 2. Or if destination is the same directory as source (no new dir needed) const sourceDir = path.dirname(params.sourceAbsolute); const needsMkdir = destDir !== dependencies.PROJECT_ROOT && destDir !== sourceDir; if (needsMkdir) { try { await dependencies.mkdir(destDir, { recursive: true }); } catch (mkdirError: unknown) { // If mkdir fails for reasons other than EEXIST, it's a critical problem for rename if (!(mkdirError && typeof mkdirError === 'object' && 'code' in mkdirError && mkdirError.code === 'EEXIST')) { console.error(`[Filesystem MCP - performMoveOperation] Critical error creating destination directory ${destDir}:`, mkdirError); // Return the mkdir error directly return handleMoveError({ error: mkdirError, sourceRelative: params.sourceOutput, // Pass relative path for better error message destinationRelative: params.destOutput, // Pass relative path for better error message sourceOutput: params.sourceOutput, destOutput: params.destOutput, }); } // Ignore EEXIST - directory already exists } } await dependencies.rename(params.sourceAbsolute, params.destinationAbsolute); // Use injected dependency return { source: params.sourceOutput, destination: params.destOutput, success: true, }; }

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/sylphxltd/filesystem-mcp'

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