Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
32 changes: 32 additions & 0 deletions src/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,38 @@ export async function handleEditBlock(args: unknown): Promise<ServerResult> {
return createErrorResponse(`Text replacement requires both old_string and new_string parameters`);
}

// DOCX files: Route through DocxFileHandler.editRange for XML-aware editing
if (parsed.file_path.toLowerCase().endsWith('.docx')) {
try {
const validatedPath = await validatePath(parsed.file_path);
const { getFileHandler } = await import('../utils/files/factory.js');
const handler = await getFileHandler(validatedPath);

if ('editRange' in handler && typeof handler.editRange === 'function') {
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}`
}],
};
} else {
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);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const searchReplace = {
search: parsed.old_string,
replace: parsed.new_string
Expand Down
3 changes: 3 additions & 0 deletions 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
Loading