Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/filesystem/__tests__/read-file-lines.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
41 changes: 30 additions & 11 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
applyFileEdits,
tailFile,
headFile,
readFileLines,
setAllowedDirectories,
} from './lib.js';

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -191,15 +194,28 @@ async function readFileAsBase64Stream(filePath: string): Promise<string> {
const readTextFileHandler = async (args: z.infer<typeof ReadTextFileArgsSchema>) => {
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);
}
Expand Down Expand Up @@ -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 }
Expand Down
32 changes: 32 additions & 0 deletions src/filesystem/lib.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -371,6 +373,36 @@ export async function headFile(filePath: string, numLines: number): Promise<stri
}
}

export async function readFileLines(filePath: string, offset: number, limit: number): Promise<string> {
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,
Expand Down
Loading