import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { z } from 'zod';
import { ErrorCode } from '../lib/errors.js';
import { atomicWriteFile, createTimedAbortSignal } from '../lib/fs-helpers.js';
import { withToolDiagnostics } from '../lib/observability.js';
import { validateExistingPath } from '../lib/path-validation.js';
import { EditFileInputSchema, EditFileOutputSchema } from '../schemas.js';
import {
buildToolErrorResponse,
buildToolResponse,
type ToolExtra,
type ToolRegistrationOptions,
type ToolResponse,
type ToolResult,
withDefaultIcons,
withToolErrorHandling,
wrapToolHandler,
} from './shared.js';
const EDIT_FILE_TOOL = {
title: 'Edit File',
description:
'Edit a file by replacing text. Sequentially applies a list of string replacements. ' +
'Replaces the first occurrence of each `oldText`.',
inputSchema: EditFileInputSchema,
outputSchema: EditFileOutputSchema,
annotations: {
readOnlyHint: false,
openWorldHint: false,
},
} as const;
interface EditResult {
content: string;
appliedEdits: number;
unmatchedEdits: string[];
lineRange?: [number, number];
}
function applyEdits(
content: string,
edits: z.infer<typeof EditFileInputSchema>['edits']
): EditResult {
let newContent = content;
let appliedEdits = 0;
const unmatchedEdits: string[] = [];
let minLine: number | undefined;
let maxLine: number | undefined;
for (const edit of edits) {
if (!newContent.includes(edit.oldText)) {
unmatchedEdits.push(edit.oldText);
continue;
}
const index = newContent.indexOf(edit.oldText);
const linesBefore = newContent.slice(0, index).split('\n').length;
const newTextLines = edit.newText.split('\n').length;
const startLine = linesBefore;
const endLine = linesBefore + newTextLines - 1;
if (minLine === undefined || startLine < minLine) minLine = startLine;
if (maxLine === undefined || endLine > maxLine) maxLine = endLine;
newContent = newContent.replace(edit.oldText, edit.newText);
appliedEdits += 1;
}
const result: EditResult = {
content: newContent,
appliedEdits,
unmatchedEdits,
};
if (minLine !== undefined && maxLine !== undefined) {
result.lineRange = [minLine, maxLine];
}
return result;
}
async function handleEditFile(
args: z.infer<typeof EditFileInputSchema>,
signal?: AbortSignal
): Promise<ToolResponse<z.infer<typeof EditFileOutputSchema>>> {
const validPath = await validateExistingPath(args.path, signal);
const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
const {
content: newContent,
appliedEdits,
unmatchedEdits,
lineRange,
} = applyEdits(content, args.edits);
const structured: z.infer<typeof EditFileOutputSchema> = {
ok: true,
path: validPath,
appliedEdits,
...(unmatchedEdits.length > 0 ? { unmatchedEdits } : {}),
...(lineRange ? { lineRange } : {}),
};
if (args.dryRun) {
return buildToolResponse(
`Dry run complete. ${appliedEdits} edits would be applied.`,
structured
);
}
if (appliedEdits > 0) {
await atomicWriteFile(validPath, newContent, { encoding: 'utf-8', signal });
}
const message =
appliedEdits === 0
? `No edits applied to ${args.path}`
: `Successfully applied ${appliedEdits} edits to ${args.path}`;
return buildToolResponse(message, structured);
}
export function registerEditFileTool(
server: McpServer,
options: ToolRegistrationOptions = {}
): void {
const handler = (
args: z.infer<typeof EditFileInputSchema>,
extra: ToolExtra
): Promise<ToolResult<z.infer<typeof EditFileOutputSchema>>> =>
withToolDiagnostics(
'edit',
() =>
withToolErrorHandling(
async () => {
const { signal, cleanup } = createTimedAbortSignal(extra.signal);
try {
return await handleEditFile(args, signal);
} finally {
cleanup();
}
},
(error) =>
buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path)
),
{ path: args.path }
);
server.registerTool(
'edit',
withDefaultIcons({ ...EDIT_FILE_TOOL }, options.iconInfo),
wrapToolHandler(handler, {
guard: options.isInitialized,
progressMessage: (args) => {
const name = path.basename(args.path);
return `π edit: ${name} (${args.edits.length} edits)`;
},
completionMessage: (args, result) => {
const name = path.basename(args.path);
if (result.isError) return `π edit: ${name} β Failed`;
const sc = result.structuredContent;
if (!sc.ok) return `π edit: ${name} β Failed`;
if (sc.lineRange) {
return `π edit: ${name} β [${sc.lineRange[0]}-${sc.lineRange[1]}]`;
}
return `π edit: ${name} β (${sc.appliedEdits ?? 0} edits)`;
},
})
);
}