Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,894 changes: 581 additions & 1,313 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"md-to-pdf": "^5.2.5",
"open": "^10.2.0",
"pdf-lib": "^1.17.1",
"pizzip": "^3.2.0",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
Expand Down
173 changes: 173 additions & 0 deletions src/search-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { validatePath } from './tools/filesystem.js';
import { capture } from './utils/capture.js';
import { getRipgrepPath } from './utils/ripgrep-resolver.js';
import { isExcelFile } from './utils/files/index.js';
import PizZip from 'pizzip';

export interface SearchResult {
file: string;
Expand Down Expand Up @@ -171,6 +172,27 @@ export interface SearchSessionOptions {
});
}

// For content searches, also search DOCX files
const shouldSearchDocx = options.searchType === 'content' &&
this.shouldIncludeDocxSearch(options.filePattern, validPath);

if (shouldSearchDocx) {
this.searchDocxFiles(
validPath,
options.pattern,
options.ignoreCase !== false,
options.maxResults,
options.filePattern
).then(docxResults => {
for (const result of docxResults) {
session.results.push(result);
session.totalMatches++;
}
}).catch((err) => {
capture('docx_search_error', { error: err instanceof Error ? err.message : String(err) });
});
}

// Wait for first chunk of data or early completion instead of fixed delay
// Excel search runs in background and results are merged via readSearchResults
const firstChunk = new Promise<void>(resolve => {
Expand Down Expand Up @@ -469,6 +491,157 @@ export interface SearchSessionOptions {
return excelFiles;
}

/**
* Determine if DOCX search should be included based on context
*/
private shouldIncludeDocxSearch(filePattern?: string, rootPath?: string): boolean {
const docxExtensions = ['.docx'];

if (rootPath) {
const lowerPath = rootPath.toLowerCase();
if (docxExtensions.some(ext => lowerPath.endsWith(ext))) {
return true;
}
}

if (filePattern) {
const lowerPattern = filePattern.toLowerCase();
if (docxExtensions.some(ext =>
lowerPattern.includes(`*${ext}`) || lowerPattern.endsWith(ext)
)) {
return true;
}
}

return false;
}

/**
* Search DOCX files for content matches
* Extracts <w:t> text from document.xml and searches it
*/
private async searchDocxFiles(
rootPath: string,
pattern: string,
ignoreCase: boolean,
maxResults?: number,
filePattern?: string
): Promise<SearchResult[]> {
const results: SearchResult[] = [];

const flags = ignoreCase ? 'i' : '';
let regex: RegExp;
try {
regex = new RegExp(pattern, flags);
} catch {
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
regex = new RegExp(escaped, flags);
}

let docxFiles = await this.findDocxFiles(rootPath);

if (filePattern) {
const patterns = filePattern.split('|').map(p => p.trim()).filter(Boolean);
docxFiles = docxFiles.filter(filePath => {
const fileName = path.basename(filePath);
return patterns.some(pat => {
if (pat.includes('*')) {
const regexPat = pat.replace(/\./g, '\\.').replace(/\*/g, '.*');
return new RegExp(`^${regexPat}$`, 'i').test(fileName);
}
return fileName.toLowerCase() === pat.toLowerCase();
});
});
}

for (const filePath of docxFiles) {
if (maxResults && results.length >= maxResults) break;

try {
const buf = await fs.readFile(filePath);
const zip = new PizZip(buf);

// Search all XML parts that can contain text
const xmlParts = ['word/document.xml', 'word/header1.xml', 'word/header2.xml',
'word/header3.xml', 'word/footer1.xml', 'word/footer2.xml', 'word/footer3.xml'];
Comment on lines +565 to +566

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The DOCX search only checks a hard-coded set of header and footer XML parts (header1-3.xml, footer1-3.xml), so any text in additional headers/footers like header4.xml or footer5.xml will never be found, even though the DOCX outline and metadata logic handle all header/footer parts dynamically; this causes real content in some documents to be silently missed by start_search. [logic error]

Severity Level: Major ⚠️
- ❌ start_search misses text in DOCX section headers.
- ⚠️ Multi-section Word documents return incomplete search results.
- ⚠️ Inconsistency between DOCX read_file outline and search coverage.
Suggested change
const xmlParts = ['word/document.xml', 'word/header1.xml', 'word/header2.xml',
'word/header3.xml', 'word/footer1.xml', 'word/footer2.xml', 'word/footer3.xml'];
const xmlParts: string[] = ['word/document.xml'];
const zipFiles = zip.files;
for (const relativePath of Object.keys(zipFiles)) {
if (
(relativePath.startsWith('word/header') || relativePath.startsWith('word/footer')) &&
relativePath.endsWith('.xml')
) {
xmlParts.push(relativePath);
}
}
Steps of Reproduction ✅
1. From the MCP client, call the `start_search` tool, which is handled by
`handleStartSearch()` in `src/handlers/search-handlers.ts:13-35`. Pass `searchType:
"content"`, `pattern: "UniqueHeaderText"`, `path: "/some/dir"`, and `filePattern:
"*.docx"` so that content search is started against DOCX files.

2. Ensure `/some/dir` contains a DOCX file (e.g., `multi-section.docx`) whose body text
does NOT contain `"UniqueHeaderText"`, but whose additional section header (e.g.,
`word/header4.xml` or `word/header5.xml`) does contain `"UniqueHeaderText"`. Word will
create these additional header parts when the document has different headers in later
sections.

3. In `SearchManager.startSearch()` at `src/search-manager.ts:59-218`, the call is routed
to `this.searchDocxFiles(...)` (lines 175-194) because `searchType === 'content'` and
`this.shouldIncludeDocxSearch(options.filePattern, validPath)` (lines 495-517) returns
true for `filePattern: "*.docx"` and the validated root path.

4. Inside `searchDocxFiles()` at `src/search-manager.ts:523-607`, the DOCX is opened and
only the hard-coded XML parts listed in `xmlParts = ['word/document.xml',
'word/header1.xml', 'word/header2.xml', 'word/header3.xml', 'word/footer1.xml',
'word/footer2.xml', 'word/footer3.xml'];` (lines 564-566) are scanned. Because
`header4.xml` and above are never iterated in the `for (const xmlPath of xmlParts)` loop
at line 568, `"UniqueHeaderText"` in those later headers is never matched, so the
`start_search` response (initial result from `handleStartSearch` or later from
`handleGetMoreSearchResults` in `src/handlers/search-handlers.ts:85-147`) shows no
results, even though the DOCX actually contains the text.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/search-manager.ts
**Line:** 565:566
**Comment:**
	*Logic Error: The DOCX search only checks a hard-coded set of header and footer XML parts (`header1-3.xml`, `footer1-3.xml`), so any text in additional headers/footers like `header4.xml` or `footer5.xml` will never be found, even though the DOCX outline and metadata logic handle all header/footer parts dynamically; this causes real content in some documents to be silently missed by `start_search`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎


for (const xmlPath of xmlParts) {
if (maxResults && results.length >= maxResults) break;

const file = zip.file(xmlPath);
if (!file) continue;

const xml = file.asText();
// Extract all <w:t> text with position tracking
const wtRe = /<w:t(?:\s[^>]*)?>([^<]*)<\/w:t>/g;
let m;
let lineNum = 0;

while ((m = wtRe.exec(xml)) !== null) {
if (maxResults && results.length >= maxResults) break;
const text = m[1];
if (!text || !text.trim()) continue;
lineNum++;

if (regex.test(text)) {
const match = text.match(regex);
const matchContext = match
? this.getMatchContext(text, match.index || 0, match[0].length)
: text.substring(0, 150);

const partName = xmlPath === 'word/document.xml' ? '' : `:${xmlPath.replace('word/', '')}`;
results.push({
file: `${filePath}${partName}`,
line: lineNum,
match: matchContext,
type: 'content'
});
}
}
}
} catch {
continue;
}
}

return results;
}

/**
* Find all DOCX files in a directory recursively
*/
private async findDocxFiles(rootPath: string): Promise<string[]> {
const docxFiles: string[] = [];
const isDocx = (name: string) => name.toLowerCase().endsWith('.docx');

async function walk(dir: string): Promise<void> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
await walk(fullPath);
}
} else if (entry.isFile() && isDocx(entry.name)) {
docxFiles.push(fullPath);
}
}
} catch { /* skip */ }
}

try {
const stats = await fs.stat(rootPath);
if (stats.isFile() && isDocx(rootPath)) {
return [rootPath];
} else if (stats.isDirectory()) {
await walk(rootPath);
}
} catch { /* skip */ }

return docxFiles;
}

/**
* Extract context around a match for display (show surrounding text)
*/
Expand Down
26 changes: 26 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
- PDF: Extracts text content as markdown with page structure
* offset/length work as page pagination (0-based)
* Includes embedded images when available
- DOCX (.docx): Two modes depending on parameters:
* DEFAULT (no offset/length): Returns a text-bearing outline — shows paragraphs with text,
tables with cell content, styles, image refs. Skips shapes/drawings/SVG noise.
Each element shows its body index [0], [1], etc.
* WITH offset/length: Returns raw pretty-printed XML with line pagination.
Use this to drill into specific sections or see the actual XML for editing.
* EDITING WORKFLOW: 1) read_file to get outline, 2) read_file with offset/length
to see raw XML around what you want to edit, 3) edit_block with old_string/new_string
using XML fragments copied from the read output.
* IMPORTANT: offset MUST be non-zero to get raw XML (use offset=1 to start from line 1).
offset=0 always returns the outline regardless of length.
* For BULK changes (translation, mass replacements): use start_process with Python
zipfile module to find/replace all <w:t> elements at once.

${PATH_GUIDANCE}
${CMD_PREFIX_DESCRIPTION}`,
Expand Down Expand Up @@ -353,6 +366,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
Write or append to file contents.

IMPORTANT: DO NOT use this tool to create PDF files. Use 'write_pdf' for all PDF creation tasks.
DO NOT use this tool to edit DOCX files. Use 'edit_block' with old_string/new_string instead.
To CREATE a new DOCX, use write_file with .docx extension — text content with markdown headings (#, ##, ###) is converted to styled DOCX paragraphs.

CHUNKING IS STANDARD PRACTICE: Always write files in chunks of 25-30 lines maximum.
This is the normal, recommended way to write files - not an emergency measure.
Expand Down Expand Up @@ -732,6 +747,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
- new_string: Replacement text
- expected_replacements: Optional number of replacements (default: 1)

DOCX FILES (.docx) - XML Find/Replace mode:
Takes same parameters as text files (old_string, new_string, expected_replacements).
Operates on the pretty-printed XML inside the DOCX — the same XML you see from
read_file with offset/length. Copy XML fragments from read output as old_string.
After editing, the XML is repacked into a valid DOCX.
Also searches headers/footers if not found in document body.
Examples:
- Replace text: old_string="<w:t>Old Text</w:t>" new_string="<w:t>New Text</w:t>"
- Change style: old_string='<w:pStyle w:val="Normal"/>' new_string='<w:pStyle w:val="Heading1"/>'
- Add content: include surrounding XML context in old_string, add new elements in new_string

By default, replaces only ONE occurrence of the search text.
To replace multiple occurrences, provide expected_replacements with
the exact number of matches expected.
Expand Down
91 changes: 62 additions & 29 deletions src/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,57 +371,90 @@ function highlightDifferences(expected: string, actual: string): string {
export async function handleEditBlock(args: unknown): Promise<ServerResult> {
const parsed = EditBlockArgsSchema.parse(args);

// Structured files: Range rewrite
// Note: Check for truthy range to handle empty strings from AI clients that send all optional params
const hasRange = parsed.range !== undefined && parsed.range !== '';
const hasContent = parsed.content !== undefined && parsed.content !== '';
if (hasRange && hasContent) {
try {
// Validate path before any filesystem operations
const validatedPath = await validatePath(parsed.file_path);

const { getFileHandler } = await import('../utils/files/factory.js');
const handler = await getFileHandler(validatedPath);
// Validate path and resolve handler once — used by both dispatch paths below
let validatedPath: string;
let handler: Awaited<ReturnType<typeof import('../utils/files/factory.js').getFileHandler>>;
try {
validatedPath = await validatePath(parsed.file_path);
const { getFileHandler } = await import('../utils/files/factory.js');
handler = await getFileHandler(validatedPath);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return createErrorResponse(errorMessage);
}

const hasEditRange = 'editRange' in handler && typeof handler.editRange === 'function';

// Parse content if it's a JSON string (AI often sends arrays as JSON strings)
let content = parsed.content;
if (typeof content === 'string') {
try {
content = JSON.parse(content);
} catch {
// Leave as-is if not valid JSON - let handler decide
}
// Path 1: Range rewrite (Excel, etc.) — range + content
if (hasRange && hasContent) {
// Parse content if it's a JSON string (AI often sends arrays as JSON strings)
let content = parsed.content;
if (typeof content === 'string') {
try {
content = JSON.parse(content);
} catch {
// Leave as-is if not valid JSON - let handler decide
}
}

// Check if handler supports range editing
if ('editRange' in handler && typeof handler.editRange === 'function') {
if (hasEditRange) {
try {
// parsed.range is guaranteed non-empty string by hasRange check above
await handler.editRange(validatedPath, parsed.range!, content, parsed.options);
await handler.editRange!(validatedPath!, parsed.range!, content, parsed.options);
return {
content: [{
type: "text",
text: `Successfully updated range ${parsed.range} in ${parsed.file_path}`
}],
};
} else {
return createErrorResponse(`Range-based editing not supported for ${parsed.file_path}. For text files, use old_string and new_string parameters instead. If your client requires range/content parameters, set them to empty strings ("").`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return createErrorResponse(errorMessage);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return createErrorResponse(errorMessage);
}
Comment on lines +404 to 418

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

editRange return value discarded in the range-rewrite path — failures silently reported as success.

await handler.editRange!(validatedPath!, parsed.range!, content, parsed.options);
return {
    content: [{ type: "text", text: `Successfully updated range ${parsed.range} ...` }],
};

DocxFileHandler.editRange never throws — it returns { success: false, errors: [...] } for all error conditions (missing old_string, no match, etc.). If a caller uses range + content syntax on a .docx file, the EditResult is discarded and the caller unconditionally receives a success message regardless of the actual outcome. Errors are visible only in Path 2 (old_string/new_string) where the result is checked.

🐛 Proposed fix — propagate EditResult failure
-            await handler.editRange!(validatedPath!, parsed.range!, content, parsed.options);
-            return {
-                content: [{
-                    type: "text",
-                    text: `Successfully updated range ${parsed.range} in ${parsed.file_path}`
-                }],
-            };
+            const rangeResult = await handler.editRange!(validatedPath!, parsed.range!, content, parsed.options);
+            if (rangeResult && !rangeResult.success) {
+                const errorMsg = rangeResult.errors?.map(e => e.error).join('; ') || 'Unknown error';
+                return createErrorResponse(errorMsg);
+            }
+            return {
+                content: [{
+                    type: "text",
+                    text: `Successfully updated range ${parsed.range} in ${parsed.file_path}`
+                }],
+            };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/edit.ts` around lines 404 - 418, The range-edit branch currently
awaits handler.editRange! but discards its EditResult, always returning a
success message; update the hasEditRange branch in the function handling edits
to capture the result from handler.editRange!(validatedPath!, parsed.range!,
content, parsed.options), then check the returned EditResult (e.g.,
result.success): if false, return createErrorResponse with the result.errors (or
a joined message) so failures from DocxFileHandler.editRange propagate,
otherwise return the existing success content; keep the existing try/catch to
handle true exceptions and convert them to createErrorResponse as before.


return createErrorResponse(`Range-based editing not supported for ${parsed.file_path}. For text files, use old_string and new_string parameters instead. If your client requires range/content parameters, set them to empty strings ("").`);
}

// Text files: String replacement
// Validate required parameters for text replacement
// Path 2: Text replacement — old_string + new_string
if (parsed.old_string === undefined || parsed.new_string === undefined) {
return createErrorResponse(`Text replacement requires both old_string and new_string parameters`);
}

const searchReplace = {
// If the handler implements editRange it owns text-replacement for its file type
// (e.g. DocxFileHandler does find/replace on pretty-printed XML rather than raw bytes).
// Plain text files fall through to performSearchReplace.
if (hasEditRange) {
Comment on lines 391 to +431

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Text-replacement requests for structured files that implement editRange (notably Excel) will now incorrectly be routed through editRange with an empty range and a DOCX-style { old_string, new_string } payload, even though editRange is intended only for range rewrites on those types; this can cause Excel edits to behave unexpectedly or fail, and silently changes the previous behavior where such calls fell back to plain text search/replace. Restricting the editRange-based text path to DOCX files only restores the intended contract (Excel uses range+content only, DOCX uses editRange for XML-aware text edits) while preserving the new DOCX functionality. [logic error]

Severity Level: Major ⚠️
- ⚠️ Excel edit_block text replacements always error with 2D-array message.
- ⚠️ Structured handlers cannot support simple text find/replace semantics.
- ⚠️ Client behavior diverges from documented text-edit expectations.
- ⚠️ Future editRange handlers risk same misrouting for text edits.
Suggested change
// Parse content if it's a JSON string (AI often sends arrays as JSON strings)
let content = parsed.content;
if (typeof content === 'string') {
try {
content = JSON.parse(content);
} catch {
// Leave as-is if not valid JSON - let handler decide
}
// Path 1: Range rewrite (Excel, etc.) — range + content
if (hasRange && hasContent) {
// Parse content if it's a JSON string (AI often sends arrays as JSON strings)
let content = parsed.content;
if (typeof content === 'string') {
try {
content = JSON.parse(content);
} catch {
// Leave as-is if not valid JSON - let handler decide
}
}
// Check if handler supports range editing
if ('editRange' in handler && typeof handler.editRange === 'function') {
if (hasEditRange) {
try {
// parsed.range is guaranteed non-empty string by hasRange check above
await handler.editRange(validatedPath, parsed.range!, content, parsed.options);
await handler.editRange!(validatedPath!, parsed.range!, content, parsed.options);
return {
content: [{
type: "text",
text: `Successfully updated range ${parsed.range} in ${parsed.file_path}`
}],
};
} else {
return createErrorResponse(`Range-based editing not supported for ${parsed.file_path}. For text files, use old_string and new_string parameters instead. If your client requires range/content parameters, set them to empty strings ("").`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return createErrorResponse(errorMessage);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return createErrorResponse(errorMessage);
}
return createErrorResponse(`Range-based editing not supported for ${parsed.file_path}. For text files, use old_string and new_string parameters instead. If your client requires range/content parameters, set them to empty strings ("").`);
}
// Text files: String replacement
// Validate required parameters for text replacement
// Path 2: Text replacement — old_string + new_string
if (parsed.old_string === undefined || parsed.new_string === undefined) {
return createErrorResponse(`Text replacement requires both old_string and new_string parameters`);
}
const searchReplace = {
// If the handler implements editRange it owns text-replacement for its file type
// (e.g. DocxFileHandler does find/replace on pretty-printed XML rather than raw bytes).
// Plain text files fall through to performSearchReplace.
if (hasEditRange) {
const isDocx = parsed.file_path.toLowerCase().endsWith('.docx');
// Path 1: Range rewrite (Excel, etc.) — range + content
if (hasRange && hasContent) {
// Parse content if it's a JSON string (AI often sends arrays as JSON strings)
let content = parsed.content;
if (typeof content === 'string') {
try {
content = JSON.parse(content);
} catch {
// Leave as-is if not valid JSON - let handler decide
}
}
if (hasEditRange) {
try {
// parsed.range is guaranteed non-empty string by hasRange check above
await handler.editRange!(validatedPath!, parsed.range!, content, parsed.options);
return {
content: [{
type: "text",
text: `Successfully updated range ${parsed.range} in ${parsed.file_path}`
}],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return createErrorResponse(errorMessage);
}
}
return createErrorResponse(`Range-based editing not supported for ${parsed.file_path}. For text files, use old_string and new_string parameters instead. If your client requires range/content parameters, set them to empty strings ("").`);
}
// Path 2: Text replacement — old_string + new_string
if (parsed.old_string === undefined || parsed.new_string === undefined) {
return createErrorResponse(`Text replacement requires both old_string and new_string parameters`);
}
// If the handler implements editRange it owns text-replacement for DOCX files
// (DocxFileHandler does find/replace on pretty-printed XML rather than raw bytes).
// Other file types (including Excel) fall through to performSearchReplace.
if (hasEditRange && isDocx) {
Steps of Reproduction ✅
1. Start the MCP server and ensure it exposes the `edit_block` command via
`handleEditBlock` re-exported in `src/handlers/edit-search-handlers.ts:1-13`.

2. Create or reuse an Excel file (e.g., `EDIT_EXCEL` in
`test/test-excel-files.js:211-218`, which is a `.xlsx` created via `writeFile`).

3. From any client (or by modifying `test/test-excel-files.js`), call `handleEditBlock`
with that `.xlsx` file path, *without* `range` or `content`, but with `old_string` and
`new_string`, for example:

   - `handleEditBlock({ file_path: EDIT_EXCEL, old_string: 'Apple', new_string: 'Pear' })`

   This mirrors the existing call pattern in `test/test-excel-files.js:221-226` but omits
   `range`/`content` and supplies text parameters instead.

4. The call reaches `handleEditBlock` in `src/tools/edit.ts:371-459`, where:

   - `hasRange` and `hasContent` are false, so Path 1 is skipped.

   - `validatedPath` is resolved and `handler` is obtained from `getFileHandler`, which
   returns an `ExcelFileHandler` for `.xlsx` files (validated by
   `test/test-excel-files.js:81-87`).

   - `hasEditRange` is true for `ExcelFileHandler` (`src/utils/files/excel.ts:158-236`
   implements `editRange`).

   - In the text-replacement path at `src/tools/edit.ts:423-454`, the code calls
   `handler.editRange!(validatedPath!, '', { old_string, new_string, expected_replacements
   })`.

5. Inside `ExcelFileHandler.editRange` (`src/utils/files/excel.ts:158-236`), the method
validates `content` with `if (!Array.isArray(content)) { throw new Error('Content must be
a 2D array for range editing'); }` at lines 170-173; since the payload is an object `{
old_string, new_string, ... }` instead of a 2D array, this throws.

6. The thrown error is caught back in `handleEditBlock` at `src/tools/edit.ts:404-417`,
and `createErrorResponse` is returned to the client with the message `"Content must be a
2D array for range editing"`, instead of performing any text search/replace or falling
through to `performSearchReplace`.

7. This misrouting will occur for any file type whose handler implements `editRange` but
expects structured range edits (currently `ExcelFileHandler`, per
`src/utils/files/base.ts:31-41` and `src/utils/files/excel.ts:33-40`), whenever a caller
issues a text-style `edit_block` request (old_string/new_string only) against such a file.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/tools/edit.ts
**Line:** 391:431
**Comment:**
	*Logic Error: Text-replacement requests for structured files that implement `editRange` (notably Excel) will now incorrectly be routed through `editRange` with an empty range and a DOCX-style `{ old_string, new_string }` payload, even though `editRange` is intended only for range rewrites on those types; this can cause Excel edits to behave unexpectedly or fail, and silently changes the previous behavior where such calls fell back to plain text search/replace. Restricting the `editRange`-based text path to DOCX files only restores the intended contract (Excel uses range+content only, DOCX uses `editRange` for XML-aware text edits) while preserving the new DOCX functionality.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

try {
const result = await handler.editRange!(validatedPath!, '', {
old_string: parsed.old_string,
new_string: parsed.new_string,
expected_replacements: parsed.expected_replacements,
});

if (result.success) {
return {
content: [{
type: "text",
text: `Successfully applied ${result.editsApplied} edit(s) to ${parsed.file_path}`
}],
};
}

const errorMsg = result.errors?.map(e => e.error).join('; ') || 'Unknown error';
return createErrorResponse(errorMsg);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return createErrorResponse(errorMessage);
}
}

return performSearchReplace(parsed.file_path, {
search: parsed.old_string,
replace: parsed.new_string
};

return performSearchReplace(parsed.file_path, searchReplace, parsed.expected_replacements);
}, parsed.expected_replacements);
}
2 changes: 1 addition & 1 deletion src/tools/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export const EditBlockArgsSchema = z.object({
data => {
// Helper to check if value is actually provided (not undefined, not empty string)
const hasValue = (v: unknown) => v !== undefined && v !== '';
return (hasValue(data.old_string) && hasValue(data.new_string)) ||
return (hasValue(data.old_string) && data.new_string !== undefined) ||
(hasValue(data.range) && hasValue(data.content));
},
{ message: "Must provide either (old_string + new_string) or (range + content)" }
Expand Down
5 changes: 4 additions & 1 deletion src/utils/files/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ export interface FileMetadata {
totalPages?: number;
pages?: PdfPageItem[];

/** For DOCX files */
isDocx?: boolean;

/** Error information if operation failed */
error?: boolean;
errorMessage?: string;
Expand Down Expand Up @@ -212,7 +215,7 @@ export interface FileInfo {
permissions: string;

/** File type classification */
fileType: 'text' | 'excel' | 'image' | 'binary';
fileType: 'text' | 'excel' | 'image' | 'binary' | 'docx';

/** Type-specific metadata */
metadata?: FileMetadata;
Expand Down
Loading