@@ -10,6 +10,7 @@ import {
1010 type Root ,
1111} from "@modelcontextprotocol/sdk/types.js" ;
1212import fs from "fs/promises" ;
13+ import { createReadStream } from "fs" ;
1314import path from "path" ;
1415import os from 'os' ;
1516import { 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+
125130const ReadMultipleFilesArgsSchema = z . object ( {
126131 paths : z . array ( z . string ( ) ) ,
127132} ) ;
@@ -374,10 +379,10 @@ async function applyFileEdits(
374379function 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
475498server . 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 }
0 commit comments