diff --git a/.changeset/5187-filesystem-head-crlf.md b/.changeset/5187-filesystem-head-crlf.md new file mode 100644 index 0000000000..afdf725cc5 --- /dev/null +++ b/.changeset/5187-filesystem-head-crlf.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": patch +--- + +`read_text_file` with `head` no longer leaves a `\r` at the end of each line of a CRLF file. Like `tail`, it returns lines normalized to LF, including a `\r\n` pair split across a read chunk. LF output is unchanged. diff --git a/src/filesystem/__tests__/server-tools.test.ts b/src/filesystem/__tests__/server-tools.test.ts index ebf3430db5..676bfeb588 100644 --- a/src/filesystem/__tests__/server-tools.test.ts +++ b/src/filesystem/__tests__/server-tools.test.ts @@ -365,6 +365,35 @@ describe("read_text_file and read_file", () => { expect(textOf(result)).toBe("three\nfour"); }); + describe("head line endings (#5187)", () => { + async function head(content: string, n: number): Promise { + const target = path.join(dir, "head.txt"); + await fs.writeFile(target, content); + return textOf( + await call(client, "read_text_file", { path: target, head: n }), + ); + } + + it("returns the first lines of an LF file unchanged", async () => { + expect(await head("a\nb\nc\n", 2)).toBe("a\nb"); + }); + + it("returns the first lines of a CRLF file without their \\r", async () => { + expect(await head("a\r\nb\r\nc\r\n", 2)).toBe("a\nb"); + }); + + it("returns a CRLF file with no final newline normalized to LF", async () => { + expect(await head("a\r\nb\r\nc", 5)).toBe("a\nb\nc"); + }); + + // headFile reads 1 KB chunks from the start. Here the first chunk ends + // on the "\r" of a "\r\n" pair whose "\n" opens the second chunk. + it("joins a CRLF pair split across a chunk boundary", async () => { + const content = "C".repeat(1023) + "\r\nnext\r\n"; + expect(await head(content, 2)).toBe("C".repeat(1023) + "\nnext"); + }); + }); + describe("tail at the end of the file (#5187)", () => { async function tail(content: string, n: number): Promise { const target = path.join(dir, "tail.txt"); diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index f1ad736b99..9e29528718 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -765,7 +765,11 @@ export async function headFile( const completeLines = buffer.slice(0, newLineIndex).split("\n"); buffer = buffer.slice(newLineIndex + 1); for (const line of completeLines) { - lines.push(line); + // Each of these lines ended in "\n"; drop the "\r" of a CRLF ending + // so head normalizes line endings the way tailFile does. A "\r" + // whose "\n" is still in the next chunk stays in the buffer until + // that "\n" arrives, so a pair split across chunks is handled too. + lines.push(line.endsWith("\r") ? line.slice(0, -1) : line); if (lines.length >= numLines) break; } }