Skip to content

filesystem: read_text_file with tail: N returns N-1 lines (or "" for tail: 1) when the file ends with a newline #5215

Open

Description

@shashanknainwal

Which server?

everything (@modelcontextprotocol/server-everything)

Server version

@modelcontextprotocol/server-filesystem 1.0.0 (npm)

How did you run the server?

npx (npm package)

Transport

stdio

Protocol era

Modern (2026-07-28)

MCP client and version

pytest-mcptest 0.1.1 (built on the official mcp Python SDK 2.3.0), over stdio. The bug doesn't depend on the client: the standalone repro in "Steps to reproduce" calls tailFile directly and shows the same result without any MCP client.

Operating system and runtime

Linux Node version: 22.22.0

Server configuration

Summary
read_text_file with tail: N treats the empty string after a file's final \n as a line. Files ending in a newline (almost every text file) get one line fewer than requested, plus a stray trailing \n. tail: 1 returns an empty string.

PR #4977 pins this as a quirk ("tail counts the trailing newline as a line"), but I couldn't find an issue for it. The tool description says tail "returns only the last N lines of the file", and head: N on the same file does return N lines, so this looks like a bug rather than intended behavior.

Steps to reproduce

  1. Create a file that ends with a newline (as most text files do):
    printf 'alpha\nbeta\ngamma\n' > /tmp/allowed/repro.txt
  2. Start the server with that directory allowed:
    npx -y @modelcontextprotocol/server-filesystem@1.0.0 /tmp/allowed
  3. Call read_text_file with {"path": "/tmp/allowed/repro.txt", "tail": 1}.
    • Expected: "gamma"
    • Actual: ""
  4. Call it again with "tail": 2.
    • Expected: "beta\ngamma"
    • Actual: "gamma\n" (only 1 line)
  5. For comparison, "head": 2 correctly returns "alpha\nbeta".

Minimal standalone repro, without an MCP client (run in an empty folder):

npm install @modelcontextprotocol/server-filesystem@1.0.0
printf 'alpha\nbeta\ngamma\n' > repro.txt
node --input-type=module -e '
import { tailFile, headFile } from "@modelcontextprotocol/server-filesystem/dist/lib.js";
for (const n of [1, 2, 3]) console.log("tail", n, JSON.stringify(await tailFile("repro.txt", n)));
console.log("head 2", JSON.stringify(await headFile("repro.txt", 2)));
'

Output:

tail 1 ""
tail 2 "gamma\n"
tail 3 "beta\ngamma\n"
head 2 "alpha\nbeta"

Expected behavior

read_text_file with tail: N returns the last N lines of the file, as the tool description says ("returns only the last N lines of the file"). A trailing newline ends the final line; it does not start an extra, empty one. This matches head: N, which returns N lines with no trailing newline.

For a file containing alpha\nbeta\ngamma\n:

  • tail: 1 → "gamma"
  • tail: 2 → "beta\ngamma"
  • tail: 3 → "alpha\nbeta\ngamma"

Actual behavior

When the file ends with a newline, tail counts the empty string after the final \n as a line. So it returns N-1 real lines plus a stray trailing \n, and tail: 1 returns an empty string:

  • tail: 1 → ""
  • tail: 2 → "gamma\n" (1 line instead of 2)
  • tail: 3 → "beta\ngamma\n" (2 lines instead of 3)

Files without a trailing newline are unaffected. Because most text files end with a newline, tail returns one line fewer than requested for almost every file.

Cause: tailFile in src/filesystem/lib.ts (line 596) does text.split("\n").slice(-numLines), and the split leaves a trailing "" element. Simply stripping the trailing newline isn't a complete fix: the read loop (line 581) stops after numLines newlines, so the first returned line can be cut off on files larger than the 1 KB chunk. The full fix and a 2,000-file differential test are in the "Additional context" section below.

Logs or error output


Already prototyped a fix?

Read until there is one more newline than lines wanted, and drop a single trailing newline before splitting:

  •    while (position > 0 && newlinesFound < numLines) {
    
  •    // One more newline than lines wanted, so the earliest returned line is
    
  •    // complete even when the file ends with a newline.
    
  •    while (position > 0 && newlinesFound <= numLines) {
    

@@

  •    const text = normalizeLineEndings(Buffer.concat(chunks).toString("utf-8"));
    
  •    let text = normalizeLineEndings(Buffer.concat(chunks).toString("utf-8"));
    
  •    // A trailing newline terminates the last line; it does not start a new, empty one.
    
  •    if (text.endsWith("\n"))
    
  •        text = text.slice(0, -1);
       return text.split("\n").slice(-numLines).join("\n");
    

This makes tail match head: neither result ends with a newline. The cost is reading at most one extra line from disk.
I ran a differential test with 2,000 random files against a reference ("last N lines; a trailing newline ends the final line"). The files had 1–40 lines, some lines longer than the 1 KB chunk, a mix of LF and CRLF, and about two-thirds ended with a trailing newline.

Before you submit

  • I searched existing issues and this is not a duplicate.
  • This is not a security vulnerability report (those go through the private advisory form).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions