Skip to content

Commit d2b217a

Browse files
authored
Merge pull request #1027 from mjherich/feat/filesystem-read-specified-lines
Add head/tail file reading and directory size listings to filesystem server
2 parents 3526039 + f41565c commit d2b217a

1 file changed

Lines changed: 211 additions & 5 deletions

File tree

src/filesystem/index.ts

Lines changed: 211 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ async function validatePath(requestedPath: string): Promise<string> {
9797
// Schema definitions
9898
const ReadFileArgsSchema = z.object({
9999
path: z.string(),
100+
tail: z.number().optional().describe('If provided, returns only the last N lines of the file'),
101+
head: z.number().optional().describe('If provided, returns only the first N lines of the file')
100102
});
101103

102104
const ReadMultipleFilesArgsSchema = z.object({
@@ -127,6 +129,11 @@ const ListDirectoryArgsSchema = z.object({
127129
path: z.string(),
128130
});
129131

132+
const ListDirectoryWithSizesArgsSchema = z.object({
133+
path: z.string(),
134+
sortBy: z.enum(['name', 'size']).optional().default('name').describe('Sort entries by name or size'),
135+
});
136+
130137
const DirectoryTreeArgsSchema = z.object({
131138
path: z.string(),
132139
});
@@ -330,6 +337,107 @@ async function applyFileEdits(
330337
return formattedDiff;
331338
}
332339

340+
// Helper functions
341+
function formatSize(bytes: number): string {
342+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
343+
if (bytes === 0) return '0 B';
344+
345+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
346+
if (i === 0) return `${bytes} ${units[i]}`;
347+
348+
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
349+
}
350+
351+
// Memory-efficient implementation to get the last N lines of a file
352+
async function tailFile(filePath: string, numLines: number): Promise<string> {
353+
const CHUNK_SIZE = 1024; // Read 1KB at a time
354+
const stats = await fs.stat(filePath);
355+
const fileSize = stats.size;
356+
357+
if (fileSize === 0) return '';
358+
359+
// Open file for reading
360+
const fileHandle = await fs.open(filePath, 'r');
361+
try {
362+
const lines: string[] = [];
363+
let position = fileSize;
364+
let chunk = Buffer.alloc(CHUNK_SIZE);
365+
let linesFound = 0;
366+
let remainingText = '';
367+
368+
// Read chunks from the end of the file until we have enough lines
369+
while (position > 0 && linesFound < numLines) {
370+
const size = Math.min(CHUNK_SIZE, position);
371+
position -= size;
372+
373+
const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
374+
if (!bytesRead) break;
375+
376+
// Get the chunk as a string and prepend any remaining text from previous iteration
377+
const readData = chunk.slice(0, bytesRead).toString('utf-8');
378+
const chunkText = readData + remainingText;
379+
380+
// Split by newlines and count
381+
const chunkLines = normalizeLineEndings(chunkText).split('\n');
382+
383+
// If this isn't the end of the file, the first line is likely incomplete
384+
// Save it to prepend to the next chunk
385+
if (position > 0) {
386+
remainingText = chunkLines[0];
387+
chunkLines.shift(); // Remove the first (incomplete) line
388+
}
389+
390+
// Add lines to our result (up to the number we need)
391+
for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) {
392+
lines.unshift(chunkLines[i]);
393+
linesFound++;
394+
}
395+
}
396+
397+
return lines.join('\n');
398+
} finally {
399+
await fileHandle.close();
400+
}
401+
}
402+
403+
// New function to get the first N lines of a file
404+
async function headFile(filePath: string, numLines: number): Promise<string> {
405+
const fileHandle = await fs.open(filePath, 'r');
406+
try {
407+
const lines: string[] = [];
408+
let buffer = '';
409+
let bytesRead = 0;
410+
const chunk = Buffer.alloc(1024); // 1KB buffer
411+
412+
// Read chunks and count lines until we have enough or reach EOF
413+
while (lines.length < numLines) {
414+
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
415+
if (result.bytesRead === 0) break; // End of file
416+
bytesRead += result.bytesRead;
417+
buffer += chunk.slice(0, result.bytesRead).toString('utf-8');
418+
419+
const newLineIndex = buffer.lastIndexOf('\n');
420+
if (newLineIndex !== -1) {
421+
const completeLines = buffer.slice(0, newLineIndex).split('\n');
422+
buffer = buffer.slice(newLineIndex + 1);
423+
for (const line of completeLines) {
424+
lines.push(line);
425+
if (lines.length >= numLines) break;
426+
}
427+
}
428+
}
429+
430+
// If there is leftover content and we still need lines, add it
431+
if (buffer.length > 0 && lines.length < numLines) {
432+
lines.push(buffer);
433+
}
434+
435+
return lines.join('\n');
436+
} finally {
437+
await fileHandle.close();
438+
}
439+
}
440+
333441
// Tool handlers
334442
server.setRequestHandler(ListToolsRequestSchema, async () => {
335443
return {
@@ -340,7 +448,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
340448
"Read the complete contents of a file from the file system. " +
341449
"Handles various text encodings and provides detailed error messages " +
342450
"if the file cannot be read. Use this tool when you need to examine " +
343-
"the contents of a single file. Only works within allowed directories.",
451+
"the contents of a single file. Use the 'head' parameter to read only " +
452+
"the first N lines of a file, or the 'tail' parameter to read only " +
453+
"the last N lines of a file. Only works within allowed directories.",
344454
inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput,
345455
},
346456
{
@@ -387,6 +497,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
387497
"finding specific files within a directory. Only works within allowed directories.",
388498
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput,
389499
},
500+
{
501+
name: "list_directory_with_sizes",
502+
description:
503+
"Get a detailed listing of all files and directories in a specified path, including sizes. " +
504+
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
505+
"prefixes. This tool is useful for understanding directory structure and " +
506+
"finding specific files within a directory. Only works within allowed directories.",
507+
inputSchema: zodToJsonSchema(ListDirectoryWithSizesArgsSchema) as ToolInput,
508+
},
390509
{
391510
name: "directory_tree",
392511
description:
@@ -451,6 +570,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
451570
throw new Error(`Invalid arguments for read_file: ${parsed.error}`);
452571
}
453572
const validPath = await validatePath(parsed.data.path);
573+
574+
if (parsed.data.head && parsed.data.tail) {
575+
throw new Error("Cannot specify both head and tail parameters simultaneously");
576+
}
577+
578+
if (parsed.data.tail) {
579+
// Use memory-efficient tail implementation for large files
580+
const tailContent = await tailFile(validPath, parsed.data.tail);
581+
return {
582+
content: [{ type: "text", text: tailContent }],
583+
};
584+
}
585+
586+
if (parsed.data.head) {
587+
// Use memory-efficient head implementation for large files
588+
const headContent = await headFile(validPath, parsed.data.head);
589+
return {
590+
content: [{ type: "text", text: headContent }],
591+
};
592+
}
593+
454594
const content = await fs.readFile(validPath, "utf-8");
455595
return {
456596
content: [{ type: "text", text: content }],
@@ -530,11 +670,77 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
530670
};
531671
}
532672

533-
case "directory_tree": {
534-
const parsed = DirectoryTreeArgsSchema.safeParse(args);
535-
if (!parsed.success) {
536-
throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`);
673+
case "list_directory_with_sizes": {
674+
const parsed = ListDirectoryWithSizesArgsSchema.safeParse(args);
675+
if (!parsed.success) {
676+
throw new Error(`Invalid arguments for list_directory_with_sizes: ${parsed.error}`);
677+
}
678+
const validPath = await validatePath(parsed.data.path);
679+
const entries = await fs.readdir(validPath, { withFileTypes: true });
680+
681+
// Get detailed information for each entry
682+
const detailedEntries = await Promise.all(
683+
entries.map(async (entry) => {
684+
const entryPath = path.join(validPath, entry.name);
685+
try {
686+
const stats = await fs.stat(entryPath);
687+
return {
688+
name: entry.name,
689+
isDirectory: entry.isDirectory(),
690+
size: stats.size,
691+
mtime: stats.mtime
692+
};
693+
} catch (error) {
694+
return {
695+
name: entry.name,
696+
isDirectory: entry.isDirectory(),
697+
size: 0,
698+
mtime: new Date(0)
699+
};
537700
}
701+
})
702+
);
703+
704+
// Sort entries based on sortBy parameter
705+
const sortedEntries = [...detailedEntries].sort((a, b) => {
706+
if (parsed.data.sortBy === 'size') {
707+
return b.size - a.size; // Descending by size
708+
}
709+
// Default sort by name
710+
return a.name.localeCompare(b.name);
711+
});
712+
713+
// Format the output
714+
const formattedEntries = sortedEntries.map(entry =>
715+
`${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${
716+
entry.isDirectory ? "" : formatSize(entry.size).padStart(10)
717+
}`
718+
);
719+
720+
// Add summary
721+
const totalFiles = detailedEntries.filter(e => !e.isDirectory).length;
722+
const totalDirs = detailedEntries.filter(e => e.isDirectory).length;
723+
const totalSize = detailedEntries.reduce((sum, entry) => sum + (entry.isDirectory ? 0 : entry.size), 0);
724+
725+
const summary = [
726+
"",
727+
`Total: ${totalFiles} files, ${totalDirs} directories`,
728+
`Combined size: ${formatSize(totalSize)}`
729+
];
730+
731+
return {
732+
content: [{
733+
type: "text",
734+
text: [...formattedEntries, ...summary].join("\n")
735+
}],
736+
};
737+
}
738+
739+
case "directory_tree": {
740+
const parsed = DirectoryTreeArgsSchema.safeParse(args);
741+
if (!parsed.success) {
742+
throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`);
743+
}
538744

539745
interface TreeEntry {
540746
name: string;

0 commit comments

Comments
 (0)