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
5 changes: 5 additions & 0 deletions .changeset/5187-filesystem-head-crlf.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions src/filesystem/__tests__/server-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string> {
const target = path.join(dir, "tail.txt");
Expand Down
6 changes: 5 additions & 1 deletion src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
Loading