From 8c910e3dd6960c7cdf82bb82bb514f09685cee3f Mon Sep 17 00:00:00 2001 From: psyche314 Date: Sat, 4 Jul 2026 02:15:39 +0800 Subject: [PATCH] feat(filesystem): support line-offset text file reads Add offset and limit parameters to read_text_file/read_file so callers can read a bounded range of lines from a text file. Also add streaming line-range reading, parameter validation, README documentation, and unit tests. Co-authored-by: ChatGPT --- src/filesystem/README.md | 3 ++ .../__tests__/read-file-lines.test.ts | 41 +++++++++++++++++++ src/filesystem/index.ts | 41 ++++++++++++++----- src/filesystem/lib.ts | 32 +++++++++++++++ 4 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 src/filesystem/__tests__/read-file-lines.test.ts diff --git a/src/filesystem/README.md b/src/filesystem/README.md index c099da1e8c..d1b76b7660 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -72,8 +72,11 @@ The server's directory access control follows this flow: - `path` (string) - `head` (number, optional): First N lines - `tail` (number, optional): Last N lines + - `offset` (number, optional): Zero-based line offset to start reading from + - `limit` (number, optional): Maximum number of lines to read from `offset` - Always treats the file as UTF-8 text regardless of extension - Cannot specify both `head` and `tail` simultaneously + - Cannot combine `head` or `tail` with `offset` or `limit`; `offset` requires `limit`, while `limit` without `offset` starts at the first line - **read_media_file** - Read an image or audio file diff --git a/src/filesystem/__tests__/read-file-lines.test.ts b/src/filesystem/__tests__/read-file-lines.test.ts new file mode 100644 index 0000000000..a79f7449a0 --- /dev/null +++ b/src/filesystem/__tests__/read-file-lines.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { readFileLines } from '../lib.js'; + +describe('readFileLines', () => { + let testDir: string; + let testFile: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'filesystem-read-lines-test-')); + testFile = path.join(testDir, 'lines.txt'); + await fs.writeFile(testFile, 'line1\nline2\nline3\nline4\nline5', 'utf-8'); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('reads a line range from a zero-based offset', async () => { + await expect(readFileLines(testFile, 2, 2)).resolves.toBe('line3\nline4'); + }); + + it('treats limit without skipped lines as reading from the first line', async () => { + await expect(readFileLines(testFile, 0, 3)).resolves.toBe('line1\nline2\nline3'); + }); + + it('returns an empty string when the limit is zero', async () => { + await expect(readFileLines(testFile, 2, 0)).resolves.toBe(''); + }); + + it('returns an empty string when the offset is past the end of the file', async () => { + await expect(readFileLines(testFile, 99, 10)).resolves.toBe(''); + }); + + it('normalizes CRLF line endings to LF in the returned text', async () => { + await fs.writeFile(testFile, 'a\r\nb\r\nc\r\n', 'utf-8'); + await expect(readFileLines(testFile, 1, 2)).resolves.toBe('b\nc'); + }); +}); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..8ce91affb7 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -25,6 +25,7 @@ import { applyFileEdits, tailFile, headFile, + readFileLines, setAllowedDirectories, } from './lib.js'; @@ -95,8 +96,10 @@ setAllowedDirectories(allowedDirectories); // Schema definitions const ReadTextFileArgsSchema = z.object({ path: z.string(), - tail: z.number().optional().describe('If provided, returns only the last N lines of the file'), - head: z.number().optional().describe('If provided, returns only the first N lines of the file') + tail: z.number().int().nonnegative().optional().describe('If provided, returns only the last N lines of the file'), + head: z.number().int().nonnegative().optional().describe('If provided, returns only the first N lines of the file'), + offset: z.number().int().nonnegative().optional().describe('If provided with limit, skips this many lines before reading'), + limit: z.number().int().nonnegative().optional().describe('If provided, returns at most this many lines starting from offset or the beginning of the file') }); const ReadMediaFileArgsSchema = z.object({ @@ -191,15 +194,28 @@ async function readFileAsBase64Stream(filePath: string): Promise { const readTextFileHandler = async (args: z.infer) => { const validPath = await validatePath(args.path); - if (args.head && args.tail) { + const head = args.head; + const tail = args.tail; + const offset = args.offset; + const limit = args.limit; + + if (head !== undefined && tail !== undefined) { throw new Error("Cannot specify both head and tail parameters simultaneously"); } + if ((head !== undefined || tail !== undefined) && (offset !== undefined || limit !== undefined)) { + throw new Error("Cannot combine head or tail with offset or limit parameters"); + } + if (offset !== undefined && limit === undefined) { + throw new Error("Cannot specify offset without limit"); + } let content: string; - if (args.tail) { - content = await tailFile(validPath, args.tail); - } else if (args.head) { - content = await headFile(validPath, args.head); + if (tail !== undefined) { + content = await tailFile(validPath, tail); + } else if (head !== undefined) { + content = await headFile(validPath, head); + } else if (limit !== undefined) { + content = await readFileLines(validPath, offset ?? 0, limit); } else { content = await readFileContent(validPath); } @@ -231,13 +247,16 @@ server.registerTool( "Handles various text encodings and provides detailed error messages " + "if the file cannot be read. Use this tool when you need to examine " + "the contents of a single file. Use the 'head' parameter to read only " + - "the first N lines of a file, or the 'tail' parameter to read only " + - "the last N lines of a file. Operates on the file as text regardless of extension. " + + "the first N lines of a file, the 'tail' parameter to read only " + + "the last N lines of a file, or the 'offset' and 'limit' parameters " + + "to read a specific line range. Operates on the file as text regardless of extension. " + "Only works within allowed directories.", inputSchema: { path: z.string(), - tail: z.number().optional().describe("If provided, returns only the last N lines of the file"), - head: z.number().optional().describe("If provided, returns only the first N lines of the file") + tail: z.number().int().nonnegative().optional().describe("If provided, returns only the last N lines of the file"), + head: z.number().int().nonnegative().optional().describe("If provided, returns only the first N lines of the file"), + offset: z.number().int().nonnegative().optional().describe("If provided with limit, skips this many lines before reading"), + limit: z.number().int().nonnegative().optional().describe("If provided, returns at most this many lines starting from offset or the beginning of the file") }, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true } diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index ce4af9f38a..81da0a7bdb 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -1,7 +1,9 @@ import fs from "fs/promises"; +import { createReadStream } from 'fs'; import path from "path"; import os from 'os'; import { randomBytes } from 'crypto'; +import { createInterface } from 'readline'; import { diffLines, createTwoFilesPatch } from 'diff'; import { minimatch } from 'minimatch'; import { normalizePath, expandHome } from './path-utils.js'; @@ -371,6 +373,36 @@ export async function headFile(filePath: string, numLines: number): Promise { + if (limit <= 0) return ''; + + const input = createReadStream(filePath, { encoding: 'utf-8' }); + const lines = createInterface({ + input, + crlfDelay: Infinity + }); + const result: string[] = []; + let lineIndex = 0; + + try { + for await (const line of lines) { + if (lineIndex++ < offset) { + continue; + } + + result.push(line); + if (result.length >= limit) { + break; + } + } + } finally { + lines.close(); + input.destroy(); + } + + return result.join('\n'); +} + export async function searchFilesWithValidation( rootPath: string, pattern: string,