Skip to content

Commit 6f11979

Browse files
authored
Merge pull request #2382 from cliffhall/main
Add media file reading in filesystem server
2 parents 2209879 + 7042758 commit 6f11979

File tree

4 files changed

+125
-46
lines changed

4 files changed

+125
-46
lines changed

package-lock.json

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/filesystem/README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,19 @@ The server's directory access control follows this flow:
7070

7171
### Tools
7272

73-
- **read_file**
74-
- Read complete contents of a file
75-
- Input: `path` (string)
76-
- Reads complete file contents with UTF-8 encoding
73+
- **read_text_file**
74+
- Read complete contents of a file as text
75+
- Inputs:
76+
- `path` (string)
77+
- `head` (number, optional): First N lines
78+
- `tail` (number, optional): Last N lines
79+
- Always treats the file as UTF-8 text regardless of extension
80+
81+
- **read_media_file**
82+
- Read an image or audio file
83+
- Inputs:
84+
- `path` (string)
85+
- Streams the file and returns base64 data with the corresponding MIME type
7786

7887
- **read_multiple_files**
7988
- Read multiple files simultaneously

src/filesystem/index.ts

Lines changed: 105 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type Root,
1111
} from "@modelcontextprotocol/sdk/types.js";
1212
import fs from "fs/promises";
13+
import { createReadStream } from "fs";
1314
import path from "path";
1415
import os from 'os';
1516
import { randomBytes } from 'crypto';
@@ -116,12 +117,16 @@ async function validatePath(requestedPath: string): Promise<string> {
116117
}
117118

118119
// Schema definitions
119-
const ReadFileArgsSchema = z.object({
120+
const ReadTextFileArgsSchema = z.object({
120121
path: z.string(),
121122
tail: z.number().optional().describe('If provided, returns only the last N lines of the file'),
122123
head: z.number().optional().describe('If provided, returns only the first N lines of the file')
123124
});
124125

126+
const ReadMediaFileArgsSchema = z.object({
127+
path: z.string()
128+
});
129+
125130
const ReadMultipleFilesArgsSchema = z.object({
126131
paths: z.array(z.string()),
127132
});
@@ -374,10 +379,10 @@ async function applyFileEdits(
374379
function formatSize(bytes: number): string {
375380
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
376381
if (bytes === 0) return '0 B';
377-
382+
378383
const i = Math.floor(Math.log(bytes) / Math.log(1024));
379384
if (i === 0) return `${bytes} ${units[i]}`;
380-
385+
381386
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
382387
}
383388

@@ -386,9 +391,9 @@ async function tailFile(filePath: string, numLines: number): Promise<string> {
386391
const CHUNK_SIZE = 1024; // Read 1KB at a time
387392
const stats = await fs.stat(filePath);
388393
const fileSize = stats.size;
389-
394+
390395
if (fileSize === 0) return '';
391-
396+
392397
// Open file for reading
393398
const fileHandle = await fs.open(filePath, 'r');
394399
try {
@@ -397,36 +402,36 @@ async function tailFile(filePath: string, numLines: number): Promise<string> {
397402
let chunk = Buffer.alloc(CHUNK_SIZE);
398403
let linesFound = 0;
399404
let remainingText = '';
400-
405+
401406
// Read chunks from the end of the file until we have enough lines
402407
while (position > 0 && linesFound < numLines) {
403408
const size = Math.min(CHUNK_SIZE, position);
404409
position -= size;
405-
410+
406411
const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
407412
if (!bytesRead) break;
408-
413+
409414
// Get the chunk as a string and prepend any remaining text from previous iteration
410415
const readData = chunk.slice(0, bytesRead).toString('utf-8');
411416
const chunkText = readData + remainingText;
412-
417+
413418
// Split by newlines and count
414419
const chunkLines = normalizeLineEndings(chunkText).split('\n');
415-
420+
416421
// If this isn't the end of the file, the first line is likely incomplete
417422
// Save it to prepend to the next chunk
418423
if (position > 0) {
419424
remainingText = chunkLines[0];
420425
chunkLines.shift(); // Remove the first (incomplete) line
421426
}
422-
427+
423428
// Add lines to our result (up to the number we need)
424429
for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) {
425430
lines.unshift(chunkLines[i]);
426431
linesFound++;
427432
}
428433
}
429-
434+
430435
return lines.join('\n');
431436
} finally {
432437
await fileHandle.close();
@@ -441,14 +446,14 @@ async function headFile(filePath: string, numLines: number): Promise<string> {
441446
let buffer = '';
442447
let bytesRead = 0;
443448
const chunk = Buffer.alloc(1024); // 1KB buffer
444-
449+
445450
// Read chunks and count lines until we have enough or reach EOF
446451
while (lines.length < numLines) {
447452
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
448453
if (result.bytesRead === 0) break; // End of file
449454
bytesRead += result.bytesRead;
450455
buffer += chunk.slice(0, result.bytesRead).toString('utf-8');
451-
456+
452457
const newLineIndex = buffer.lastIndexOf('\n');
453458
if (newLineIndex !== -1) {
454459
const completeLines = buffer.slice(0, newLineIndex).split('\n');
@@ -459,32 +464,63 @@ async function headFile(filePath: string, numLines: number): Promise<string> {
459464
}
460465
}
461466
}
462-
467+
463468
// If there is leftover content and we still need lines, add it
464469
if (buffer.length > 0 && lines.length < numLines) {
465470
lines.push(buffer);
466471
}
467-
472+
468473
return lines.join('\n');
469474
} finally {
470475
await fileHandle.close();
471476
}
472477
}
473478

479+
// Reads a file as a stream of buffers, concatenates them, and then encodes
480+
// the result to a Base64 string. This is a memory-efficient way to handle
481+
// binary data from a stream before the final encoding.
482+
async function readFileAsBase64Stream(filePath: string): Promise<string> {
483+
return new Promise((resolve, reject) => {
484+
const stream = createReadStream(filePath);
485+
const chunks: Buffer[] = [];
486+
stream.on('data', (chunk) => {
487+
chunks.push(chunk as Buffer);
488+
});
489+
stream.on('end', () => {
490+
const finalBuffer = Buffer.concat(chunks);
491+
resolve(finalBuffer.toString('base64'));
492+
});
493+
stream.on('error', (err) => reject(err));
494+
});
495+
}
496+
474497
// Tool handlers
475498
server.setRequestHandler(ListToolsRequestSchema, async () => {
476499
return {
477500
tools: [
478501
{
479502
name: "read_file",
503+
description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.",
504+
inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput,
505+
},
506+
{
507+
name: "read_text_file",
480508
description:
481-
"Read the complete contents of a file from the file system. " +
509+
"Read the complete contents of a file from the file system as text. " +
482510
"Handles various text encodings and provides detailed error messages " +
483511
"if the file cannot be read. Use this tool when you need to examine " +
484512
"the contents of a single file. Use the 'head' parameter to read only " +
485513
"the first N lines of a file, or the 'tail' parameter to read only " +
486-
"the last N lines of a file. Only works within allowed directories.",
487-
inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput,
514+
"the last N lines of a file. Operates on the file as text regardless of extension. " +
515+
"Only works within allowed directories.",
516+
inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput,
517+
},
518+
{
519+
name: "read_media_file",
520+
description:
521+
"Read an image or audio file. Returns the base64 encoded data and MIME type. " +
522+
"Only works within allowed directories.",
523+
inputSchema: zodToJsonSchema(ReadMediaFileArgsSchema) as ToolInput,
488524
},
489525
{
490526
name: "read_multiple_files",
@@ -597,39 +633,72 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
597633
const { name, arguments: args } = request.params;
598634

599635
switch (name) {
600-
case "read_file": {
601-
const parsed = ReadFileArgsSchema.safeParse(args);
636+
case "read_file":
637+
case "read_text_file": {
638+
const parsed = ReadTextFileArgsSchema.safeParse(args);
602639
if (!parsed.success) {
603-
throw new Error(`Invalid arguments for read_file: ${parsed.error}`);
640+
throw new Error(`Invalid arguments for read_text_file: ${parsed.error}`);
604641
}
605642
const validPath = await validatePath(parsed.data.path);
606-
643+
607644
if (parsed.data.head && parsed.data.tail) {
608645
throw new Error("Cannot specify both head and tail parameters simultaneously");
609646
}
610-
647+
611648
if (parsed.data.tail) {
612649
// Use memory-efficient tail implementation for large files
613650
const tailContent = await tailFile(validPath, parsed.data.tail);
614651
return {
615652
content: [{ type: "text", text: tailContent }],
616653
};
617654
}
618-
655+
619656
if (parsed.data.head) {
620657
// Use memory-efficient head implementation for large files
621658
const headContent = await headFile(validPath, parsed.data.head);
622659
return {
623660
content: [{ type: "text", text: headContent }],
624661
};
625662
}
626-
663+
627664
const content = await fs.readFile(validPath, "utf-8");
628665
return {
629666
content: [{ type: "text", text: content }],
630667
};
631668
}
632669

670+
case "read_media_file": {
671+
const parsed = ReadMediaFileArgsSchema.safeParse(args);
672+
if (!parsed.success) {
673+
throw new Error(`Invalid arguments for read_media_file: ${parsed.error}`);
674+
}
675+
const validPath = await validatePath(parsed.data.path);
676+
const extension = path.extname(validPath).toLowerCase();
677+
const mimeTypes: Record<string, string> = {
678+
".png": "image/png",
679+
".jpg": "image/jpeg",
680+
".jpeg": "image/jpeg",
681+
".gif": "image/gif",
682+
".webp": "image/webp",
683+
".bmp": "image/bmp",
684+
".svg": "image/svg+xml",
685+
".mp3": "audio/mpeg",
686+
".wav": "audio/wav",
687+
".ogg": "audio/ogg",
688+
".flac": "audio/flac",
689+
};
690+
const mimeType = mimeTypes[extension] || "application/octet-stream";
691+
const data = await readFileAsBase64Stream(validPath);
692+
const type = mimeType.startsWith("image/")
693+
? "image"
694+
: mimeType.startsWith("audio/")
695+
? "audio"
696+
: "blob";
697+
return {
698+
content: [{ type, data, mimeType }],
699+
};
700+
}
701+
633702
case "read_multiple_files": {
634703
const parsed = ReadMultipleFilesArgsSchema.safeParse(args);
635704
if (!parsed.success) {
@@ -734,7 +803,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
734803
}
735804
const validPath = await validatePath(parsed.data.path);
736805
const entries = await fs.readdir(validPath, { withFileTypes: true });
737-
806+
738807
// Get detailed information for each entry
739808
const detailedEntries = await Promise.all(
740809
entries.map(async (entry) => {
@@ -757,7 +826,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
757826
}
758827
})
759828
);
760-
829+
761830
// Sort entries based on sortBy parameter
762831
const sortedEntries = [...detailedEntries].sort((a, b) => {
763832
if (parsed.data.sortBy === 'size') {
@@ -766,29 +835,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
766835
// Default sort by name
767836
return a.name.localeCompare(b.name);
768837
});
769-
838+
770839
// Format the output
771-
const formattedEntries = sortedEntries.map(entry =>
840+
const formattedEntries = sortedEntries.map(entry =>
772841
`${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${
773842
entry.isDirectory ? "" : formatSize(entry.size).padStart(10)
774843
}`
775844
);
776-
845+
777846
// Add summary
778847
const totalFiles = detailedEntries.filter(e => !e.isDirectory).length;
779848
const totalDirs = detailedEntries.filter(e => e.isDirectory).length;
780849
const totalSize = detailedEntries.reduce((sum, entry) => sum + (entry.isDirectory ? 0 : entry.size), 0);
781-
850+
782851
const summary = [
783852
"",
784853
`Total: ${totalFiles} files, ${totalDirs} directories`,
785854
`Combined size: ${formatSize(totalSize)}`
786855
];
787-
856+
788857
return {
789-
content: [{
790-
type: "text",
791-
text: [...formattedEntries, ...summary].join("\n")
858+
content: [{
859+
type: "text",
860+
text: [...formattedEntries, ...summary].join("\n")
792861
}],
793862
};
794863
}

src/filesystem/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"test": "jest --config=jest.config.cjs --coverage"
2121
},
2222
"dependencies": {
23-
"@modelcontextprotocol/sdk": "^1.12.3",
23+
"@modelcontextprotocol/sdk": "^1.17.0",
2424
"diff": "^5.1.0",
2525
"glob": "^10.3.10",
2626
"minimatch": "^10.0.1",
@@ -38,4 +38,4 @@
3838
"ts-node": "^10.9.2",
3939
"typescript": "^5.8.2"
4040
}
41-
}
41+
}

0 commit comments

Comments
 (0)