Skip to main content
Glama
fs-write.tool.ts27 kB
/** * fs_write Tool * * Unified modification tool for create, update, and delete operations. */ import fs from 'node:fs/promises'; import path from 'node:path'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { deleteLines, findMatches, findUniqueMatch, generateChecksum, generateDiff, getMounts, insertAfterLine, insertBeforeLine, isTextFile, type PatternMode, parseLineRange, replaceAllMatches, replaceLines, resolvePath as resolveVirtualPath, } from '../lib/index.js'; import type { HandlerExtra } from '../types/index.js'; // ───────────────────────────────────────────────────────────── // Schema // ───────────────────────────────────────────────────────────── export const fsWriteInputSchema = z .object({ path: z .string() .min(1) .describe( 'Relative path to the file. For create: where to create. For update/delete: file to modify. ' + 'Parent directories are created automatically for new files.', ), operation: z .enum(['create', 'update', 'delete']) .describe( 'REQUIRED. The operation type: ' + '"create" = make new file (fails if exists), ' + '"update" = modify existing file (requires "action" parameter), ' + '"delete" = remove file permanently. ' + 'NOTE: This is "operation", not "action".', ), // Targeting (for update) lines: z .string() .optional() .describe( 'Target specific lines for update. Format: "10" (line 10), "10-15" (lines 10-15 inclusive). ' + 'PREFERRED over pattern — line numbers are unambiguous. Get line numbers from fs_read output.', ), pattern: z .string() .optional() .describe( "Target content by pattern for update. Use when you don't have line numbers. " + 'The FIRST match is used. If multiple matches exist, use lines instead.', ), patternMode: z .enum(['literal', 'regex', 'fuzzy']) .optional() .default('literal') .describe( '"literal" (default): Exact text match. "regex": Regular expression. ' + '"fuzzy": Normalizes whitespace.', ), caseInsensitive: z .boolean() .optional() .default(false) .describe('Ignore case when matching patterns. Works with all pattern modes.'), multiline: z .boolean() .optional() .default(false) .describe( 'Enable patterns to span multiple lines. The dot (.) will match newlines. ' + 'Note: ^ and $ still match string boundaries, not line boundaries.', ), replaceAll: z .boolean() .optional() .default(false) .describe( 'When true with pattern+replace, replaces ALL occurrences instead of requiring unique match. ' + 'Useful for bulk renames like [[OldName]] → [[NewName]] across a file. ' + 'Use with caution — preview with dryRun=true first.', ), // Action (for update) action: z .enum(['replace', 'insert_before', 'insert_after', 'delete_lines']) .optional() .describe( 'REQUIRED when operation="update". Specifies what to do with targeted content: ' + '"replace" = replace target with new content, ' + '"insert_before" = add content before target, ' + '"insert_after" = add content after target, ' + '"delete_lines" = remove target lines. ' + 'NOTE: This is "action" (sub-operation), different from "operation" (main type).', ), content: z .string() .optional() .describe( 'The content to write. Required for create, replace, insert_before, insert_after. ' + 'Not needed for delete or delete_lines.', ), // Safety checksum: z .string() .optional() .describe( 'Expected checksum of the current file (from previous fs_read). ' + 'If provided and file has changed, operation fails. STRONGLY RECOMMENDED for updates.', ), dryRun: z .boolean() .optional() .default(false) .describe( 'If true, returns what WOULD change without applying it. ' + 'Returns a unified diff. Use to preview and verify complex edits.', ), createDirs: z .boolean() .optional() .default(true) .describe('For create: whether to create parent directories if missing. Default true.'), }) .passthrough() // Allow extra keys from SDK context .refine( (data) => { if (data.operation === 'create' && !data.content) { return false; } return true; }, { message: 'content is required for create operation', path: ['content'] }, ) .refine( (data) => { if (data.operation === 'update' && !data.action) { return false; } return true; }, { message: '"action" parameter is required when operation="update". Use action="replace", "insert_before", "insert_after", or "delete_lines".', path: ['action'] }, ) .refine( (data) => { if (data.operation === 'update' && data.action !== 'delete_lines' && !data.content) { return false; } return true; }, { message: 'content is required for replace/insert actions', path: ['content'] }, ); export type FsWriteInput = z.infer<typeof fsWriteInputSchema>; // ───────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────── interface FsWriteResult { success: boolean; path: string; operation: 'create' | 'update' | 'delete'; applied: boolean; result?: { action: string; linesAffected?: number; newChecksum?: string; diff?: string; }; error?: { code: string; message: string; recoveryHint?: string; }; hint: string; } // ───────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────── async function fileExists(absPath: string): Promise<boolean> { try { await fs.access(absPath); return true; } catch { return false; } } // ───────────────────────────────────────────────────────────── // Operations // ───────────────────────────────────────────────────────────── async function createFile( absPath: string, relativePath: string, content: string, options: { createDirs: boolean; dryRun: boolean }, ): Promise<FsWriteResult> { // Check if exists if (await fileExists(absPath)) { return { success: false, path: relativePath, operation: 'create', applied: false, error: { code: 'ALREADY_EXISTS', message: `File already exists: ${relativePath}`, recoveryHint: 'Use operation="update" to modify existing files.', }, hint: 'File already exists. Use fs_write with operation="update" to modify it, or choose a different path.', }; } if (options.dryRun) { const diff = generateDiff('', content, relativePath); return { success: true, path: relativePath, operation: 'create', applied: false, result: { action: 'would_create', linesAffected: content.split('\n').length, diff, }, hint: 'DRY RUN — file would be created with the content shown. Run with dryRun=false to apply.', }; } // Create parent dirs if needed if (options.createDirs) { await fs.mkdir(path.dirname(absPath), { recursive: true }); } await fs.writeFile(absPath, content, 'utf8'); const newChecksum = generateChecksum(content); return { success: true, path: relativePath, operation: 'create', applied: true, result: { action: 'created', linesAffected: content.split('\n').length, newChecksum, }, hint: `File created successfully. New checksum: ${newChecksum}. Use fs_read to verify the content.`, }; } async function deleteFile( absPath: string, relativePath: string, dryRun: boolean, ): Promise<FsWriteResult> { if (!(await fileExists(absPath))) { return { success: false, path: relativePath, operation: 'delete', applied: false, error: { code: 'NOT_FOUND', message: `File does not exist: ${relativePath}`, }, hint: 'File not found. Use fs_read to check if the path is correct.', }; } if (dryRun) { return { success: true, path: relativePath, operation: 'delete', applied: false, result: { action: 'would_delete', }, hint: 'DRY RUN — file would be deleted. Run with dryRun=false to apply.', }; } await fs.unlink(absPath); return { success: true, path: relativePath, operation: 'delete', applied: true, result: { action: 'deleted', }, hint: 'File deleted successfully.', }; } async function updateFile( absPath: string, relativePath: string, input: FsWriteInput, ): Promise<FsWriteResult> { // Check exists if (!(await fileExists(absPath))) { return { success: false, path: relativePath, operation: 'update', applied: false, error: { code: 'NOT_FOUND', message: `File does not exist: ${relativePath}`, recoveryHint: 'Use operation="create" to create a new file.', }, hint: 'File not found. Use fs_write with operation="create" to create it.', }; } // Check text file if (!isTextFile(absPath)) { return { success: false, path: relativePath, operation: 'update', applied: false, error: { code: 'NOT_TEXT', message: 'Cannot modify binary files', }, hint: 'Only text files can be modified.', }; } // Read current content const currentContent = await fs.readFile(absPath, 'utf8'); const currentChecksum = generateChecksum(currentContent); // Verify checksum if provided if (input.checksum && input.checksum !== currentChecksum) { return { success: false, path: relativePath, operation: 'update', applied: false, error: { code: 'CHECKSUM_MISMATCH', message: `File has changed. Expected: ${input.checksum}, actual: ${currentChecksum}`, recoveryHint: 'Re-read the file to get the current content and checksum.', }, hint: `Checksum mismatch — file changed since your last read. Use fs_read to get current content and new checksum (${currentChecksum}).`, }; } // Determine target range let targetStart: number; let targetEnd: number; let patternMatch: { index: number; text: string } | null = null; if (input.lines) { // Line-based targeting const range = parseLineRange(input.lines); if (!range) { return { success: false, path: relativePath, operation: 'update', applied: false, error: { code: 'INVALID_RANGE', message: `Invalid line range: ${input.lines}`, }, hint: 'Line range format: "10" for single line, "10-15" for range.', }; } const lines = currentContent.split('\n'); if (range.start > lines.length) { return { success: false, path: relativePath, operation: 'update', applied: false, error: { code: 'OUT_OF_RANGE', message: `Line ${range.start} is beyond file end (${lines.length} lines)`, }, hint: `File has ${lines.length} lines. Adjust your line range.`, }; } targetStart = range.start; targetEnd = Math.min(range.end, lines.length); } else if (input.pattern) { // Pattern-based targeting // Check if replaceAll is requested if (input.replaceAll && input.action === 'replace') { // ReplaceAll mode: replace all occurrences const content = input.content ?? ''; const result = replaceAllMatches( currentContent, input.pattern, content, input.patternMode as PatternMode, { multiline: input.multiline, caseInsensitive: input.caseInsensitive }, ); if (result.count === 0) { return { success: false, path: relativePath, operation: 'update', applied: false, error: { code: 'PATTERN_NOT_FOUND', message: `Pattern not found: "${input.pattern}"`, recoveryHint: 'Read the file to see current content, or try patternMode="fuzzy".', }, hint: 'Pattern not found. Use fs_read to see current content and find the correct pattern.', }; } // Generate diff const diff = generateDiff(currentContent, result.newContent, relativePath); if (input.dryRun) { return { success: true, path: relativePath, operation: 'update', applied: false, result: { action: 'would_replace_all', linesAffected: result.affectedLines.length, diff, }, hint: `DRY RUN — would replace ${result.count} occurrence(s) at lines ${result.affectedLines.join(', ')}. Run with dryRun=false to apply.`, }; } // Apply changes await fs.writeFile(absPath, result.newContent, 'utf8'); const newChecksum = generateChecksum(result.newContent); return { success: true, path: relativePath, operation: 'update', applied: true, result: { action: 'replaced_all', linesAffected: result.affectedLines.length, newChecksum, diff, }, hint: `Replaced ${result.count} occurrence(s) at lines ${result.affectedLines.join(', ')}. New checksum: ${newChecksum}.`, }; } // Single match mode (default) const matchResult = findUniqueMatch( currentContent, input.pattern, input.patternMode as PatternMode, { multiline: input.multiline, caseInsensitive: input.caseInsensitive, }, ); if ('error' in matchResult) { if (matchResult.error === 'not_found') { return { success: false, path: relativePath, operation: 'update', applied: false, error: { code: 'PATTERN_NOT_FOUND', message: `Pattern not found: "${input.pattern}"`, recoveryHint: 'Read the file to see current content, or try patternMode="fuzzy".', }, hint: 'Pattern not found. Use fs_read to see current content and find the correct pattern.', }; } else { // Multiple matches found - suggest replaceAll or specific line const matches = findMatches( currentContent, input.pattern, input.patternMode as PatternMode, { multiline: input.multiline, caseInsensitive: input.caseInsensitive, maxMatches: 10, }, ); return { success: false, path: relativePath, operation: 'update', applied: false, error: { code: 'MULTIPLE_MATCHES', message: `Pattern matched ${matchResult.count} times at lines ${matchResult.lines.join(', ')}`, recoveryHint: 'Use replaceAll=true to replace all, or lines="N" to target specific match.', }, hint: `Pattern matched ${matchResult.count} times at lines ${matchResult.lines.join(', ')}. Options: (1) Use replaceAll=true to replace all occurrences, or (2) Use lines="${matches[0]?.line}" to target the first match.`, }; } } const match = matchResult.match; targetStart = match.line; // For multiline matches, calculate end line const matchLines = match.text.split('\n').length; targetEnd = match.line + matchLines - 1; // Store match for substring replacement patternMatch = match; } else { return { success: false, path: relativePath, operation: 'update', applied: false, error: { code: 'NO_TARGET', message: 'Either lines or pattern must be specified for update', }, hint: 'Specify lines="10-15" or pattern="text to find" to target content for modification.', }; } // Apply action let newContent: string; let actionDescription: string; let linesAffected: number; // Content is guaranteed by Zod schema for non-delete actions const content = input.content ?? ''; switch (input.action) { case 'replace': if (patternMatch) { // Substring replacement: replace only the matched text newContent = currentContent.slice(0, patternMatch.index) + content + currentContent.slice(patternMatch.index + patternMatch.text.length); actionDescription = 'replaced'; // Calculate actual lines affected (old match lines + new content lines - overlap) const oldMatchLines = patternMatch.text.split('\n').length; const newContentLines = content.split('\n').length; linesAffected = Math.max(oldMatchLines, newContentLines); } else { // Line-based replacement (when using lines="N-M") newContent = replaceLines(currentContent, targetStart, targetEnd, content); actionDescription = 'replaced'; linesAffected = targetEnd - targetStart + 1; } break; case 'insert_before': newContent = insertBeforeLine(currentContent, targetStart, content); actionDescription = 'inserted_before'; linesAffected = content.split('\n').length; break; case 'insert_after': newContent = insertAfterLine(currentContent, targetEnd, content); actionDescription = 'inserted_after'; linesAffected = content.split('\n').length; break; case 'delete_lines': newContent = deleteLines(currentContent, targetStart, targetEnd); actionDescription = 'deleted_lines'; linesAffected = targetEnd - targetStart + 1; break; default: return { success: false, path: relativePath, operation: 'update', applied: false, error: { code: 'INVALID_ACTION', message: `Unknown action: ${input.action}`, }, hint: 'Valid actions: replace, insert_before, insert_after, delete_lines', }; } // Generate diff const diff = generateDiff(currentContent, newContent, relativePath); if (input.dryRun) { return { success: true, path: relativePath, operation: 'update', applied: false, result: { action: `would_${actionDescription}`, linesAffected, diff, }, hint: 'DRY RUN — no changes applied. Review the diff above. Run with dryRun=false to apply.', }; } // Apply changes await fs.writeFile(absPath, newContent, 'utf8'); const newChecksum = generateChecksum(newContent); return { success: true, path: relativePath, operation: 'update', applied: true, result: { action: actionDescription, linesAffected, newChecksum, diff, }, hint: `${actionDescription.replace('_', ' ')} ${linesAffected} line(s). New checksum: ${newChecksum}. The diff above shows what changed.`, }; } // ───────────────────────────────────────────────────────────── // Handler // ───────────────────────────────────────────────────────────── export const fsWriteTool = { name: 'fs_write', description: `Create, modify, or delete files in the sandboxed filesystem. 🔒 SANDBOXED FILESYSTEM — This tool can ONLY write to specific mounted directories. You CANNOT write to arbitrary system paths like /Users or C:\\. Use fs_read(".") first to see available mounts. ⚠️ PREREQUISITE: You MUST call fs_read on a file BEFORE modifying it. This gives you: (1) current content, (2) line numbers, (3) checksum. ═══════════════════════════════════════════════════════════ SAFE WORKFLOW ═══════════════════════════════════════════════════════════ 1. fs_read("path/file.md") → get content + checksum 2. fs_write with dryRun=true → preview diff 3. fs_write with dryRun=false + checksum → apply change 4. Verify diff in response matches your intent ═══════════════════════════════════════════════════════════ OPERATIONS ═══════════════════════════════════════════════════════════ CREATE — Make a new file Required: path, content Creates parent directories automatically. Fails if file already exists (use update to modify). UPDATE — Modify existing file Required: path, action, content (except for delete_lines) Target using EITHER: - lines: "10-15" — PREFERRED, unambiguous - pattern: "text" — use when line numbers unknown DELETE — Remove a file Required: path only. Cannot be undone. ═══════════════════════════════════════════════════════════ ACTIONS (for update) ═══════════════════════════════════════════════════════════ - replace: Replace target lines/pattern with new content - insert_before: Add content before target - insert_after: Add content after target - delete_lines: Remove target lines ═══════════════════════════════════════════════════════════ REPLACE ALL ═══════════════════════════════════════════════════════════ To replace ALL occurrences of a pattern (e.g., rename [[OldName]] → [[NewName]]): { pattern: "[[OldName]]", replaceAll: true, content: "[[NewName]]" } Without replaceAll, multiple matches cause an error. Always use dryRun=true first to preview bulk changes. ═══════════════════════════════════════════════════════════ SAFETY ═══════════════════════════════════════════════════════════ - checksum: Pass from fs_read to prevent stale overwrites - dryRun: Preview diff without applying (ALWAYS use first) 🚫 DO NOT call fs_write without first calling fs_read on the same file.`, inputSchema: fsWriteInputSchema, handler: async (args: unknown, _extra: HandlerExtra): Promise<CallToolResult> => { // Validate const parsed = fsWriteInputSchema.safeParse(args); if (!parsed.success) { return { isError: true, content: [ { type: 'text', text: `Invalid input: ${parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`, }, ], }; } const input = parsed.data; // Resolve virtual path to real path const resolved = resolveVirtualPath(input.path); if (!resolved.ok) { const mounts = getMounts(); const mountExample = mounts[0]?.name ?? 'vault'; // Detect if user tried an absolute path const isAbsolute = input.path.startsWith('/') || /^[a-zA-Z]:[/\\]/.test(input.path); const result: FsWriteResult = { success: false, path: input.path, operation: input.operation, applied: false, error: { code: 'OUT_OF_SCOPE', message: resolved.error }, hint: isAbsolute ? `This is a SANDBOXED filesystem — you cannot write to arbitrary system paths. ` + `Use fs_read(".") first to see available mounts, then write to paths like "${mountExample}/file.md".` : `Path must be within a mount. Example: "${mountExample}/file.md". ` + `Use fs_read(".") to see available mounts.`, }; return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } const { absolutePath, virtualPath } = resolved.resolved; let result: FsWriteResult; switch (input.operation) { case 'create': // Content is guaranteed by Zod schema for create operation result = await createFile(absolutePath, virtualPath, input.content ?? '', { createDirs: input.createDirs, dryRun: input.dryRun, }); break; case 'delete': result = await deleteFile(absolutePath, virtualPath, input.dryRun); break; case 'update': result = await updateFile(absolutePath, virtualPath, input); break; default: result = { success: false, path: virtualPath, operation: input.operation, applied: false, error: { code: 'INVALID_OPERATION', message: `Unknown operation: ${input.operation}` }, hint: 'Valid operations: create, update, delete', }; } return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, };

Latest Blog Posts

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/iceener/files-stdio-mcp-server'

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