@@ -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,58 @@ 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 {
479- name : "read_file " ,
502+ name : "read_text_file " ,
480503 description :
481- "Read the complete contents of a file from the file system. " +
504+ "Read the complete contents of a file from the file system as text . " +
482505 "Handles various text encodings and provides detailed error messages " +
483506 "if the file cannot be read. Use this tool when you need to examine " +
484507 "the contents of a single file. Use the 'head' parameter to read only " +
485508 "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 ,
509+ "the last N lines of a file. Operates on the file as text regardless of extension. " +
510+ "Only works within allowed directories." ,
511+ inputSchema : zodToJsonSchema ( ReadTextFileArgsSchema ) as ToolInput ,
512+ } ,
513+ {
514+ name : "read_media_file" ,
515+ description :
516+ "Read an image or audio file. Returns the base64 encoded data and MIME type. " +
517+ "Only works within allowed directories." ,
518+ inputSchema : zodToJsonSchema ( ReadMediaFileArgsSchema ) as ToolInput ,
488519 } ,
489520 {
490521 name : "read_multiple_files" ,
@@ -597,39 +628,71 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
597628 const { name, arguments : args } = request . params ;
598629
599630 switch ( name ) {
600- case "read_file " : {
601- const parsed = ReadFileArgsSchema . safeParse ( args ) ;
631+ case "read_text_file " : {
632+ const parsed = ReadTextFileArgsSchema . safeParse ( args ) ;
602633 if ( ! parsed . success ) {
603- throw new Error ( `Invalid arguments for read_file : ${ parsed . error } ` ) ;
634+ throw new Error ( `Invalid arguments for read_text_file : ${ parsed . error } ` ) ;
604635 }
605636 const validPath = await validatePath ( parsed . data . path ) ;
606-
637+
607638 if ( parsed . data . head && parsed . data . tail ) {
608639 throw new Error ( "Cannot specify both head and tail parameters simultaneously" ) ;
609640 }
610-
641+
611642 if ( parsed . data . tail ) {
612643 // Use memory-efficient tail implementation for large files
613644 const tailContent = await tailFile ( validPath , parsed . data . tail ) ;
614645 return {
615646 content : [ { type : "text" , text : tailContent } ] ,
616647 } ;
617648 }
618-
649+
619650 if ( parsed . data . head ) {
620651 // Use memory-efficient head implementation for large files
621652 const headContent = await headFile ( validPath , parsed . data . head ) ;
622653 return {
623654 content : [ { type : "text" , text : headContent } ] ,
624655 } ;
625656 }
626-
657+
627658 const content = await fs . readFile ( validPath , "utf-8" ) ;
628659 return {
629660 content : [ { type : "text" , text : content } ] ,
630661 } ;
631662 }
632663
664+ case "read_media_file" : {
665+ const parsed = ReadMediaFileArgsSchema . safeParse ( args ) ;
666+ if ( ! parsed . success ) {
667+ throw new Error ( `Invalid arguments for read_media_file: ${ parsed . error } ` ) ;
668+ }
669+ const validPath = await validatePath ( parsed . data . path ) ;
670+ const extension = path . extname ( validPath ) . toLowerCase ( ) ;
671+ const mimeTypes : Record < string , string > = {
672+ ".png" : "image/png" ,
673+ ".jpg" : "image/jpeg" ,
674+ ".jpeg" : "image/jpeg" ,
675+ ".gif" : "image/gif" ,
676+ ".webp" : "image/webp" ,
677+ ".bmp" : "image/bmp" ,
678+ ".svg" : "image/svg+xml" ,
679+ ".mp3" : "audio/mpeg" ,
680+ ".wav" : "audio/wav" ,
681+ ".ogg" : "audio/ogg" ,
682+ ".flac" : "audio/flac" ,
683+ } ;
684+ const mimeType = mimeTypes [ extension ] || "application/octet-stream" ;
685+ const data = await readFileAsBase64Stream ( validPath ) ;
686+ const type = mimeType . startsWith ( "image/" )
687+ ? "image"
688+ : mimeType . startsWith ( "audio/" )
689+ ? "audio"
690+ : "blob" ;
691+ return {
692+ content : [ { type, data, mimeType } ] ,
693+ } ;
694+ }
695+
633696 case "read_multiple_files" : {
634697 const parsed = ReadMultipleFilesArgsSchema . safeParse ( args ) ;
635698 if ( ! parsed . success ) {
@@ -734,7 +797,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
734797 }
735798 const validPath = await validatePath ( parsed . data . path ) ;
736799 const entries = await fs . readdir ( validPath , { withFileTypes : true } ) ;
737-
800+
738801 // Get detailed information for each entry
739802 const detailedEntries = await Promise . all (
740803 entries . map ( async ( entry ) => {
@@ -757,7 +820,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
757820 }
758821 } )
759822 ) ;
760-
823+
761824 // Sort entries based on sortBy parameter
762825 const sortedEntries = [ ...detailedEntries ] . sort ( ( a , b ) => {
763826 if ( parsed . data . sortBy === 'size' ) {
@@ -766,29 +829,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
766829 // Default sort by name
767830 return a . name . localeCompare ( b . name ) ;
768831 } ) ;
769-
832+
770833 // Format the output
771- const formattedEntries = sortedEntries . map ( entry =>
834+ const formattedEntries = sortedEntries . map ( entry =>
772835 `${ entry . isDirectory ? "[DIR]" : "[FILE]" } ${ entry . name . padEnd ( 30 ) } ${
773836 entry . isDirectory ? "" : formatSize ( entry . size ) . padStart ( 10 )
774837 } `
775838 ) ;
776-
839+
777840 // Add summary
778841 const totalFiles = detailedEntries . filter ( e => ! e . isDirectory ) . length ;
779842 const totalDirs = detailedEntries . filter ( e => e . isDirectory ) . length ;
780843 const totalSize = detailedEntries . reduce ( ( sum , entry ) => sum + ( entry . isDirectory ? 0 : entry . size ) , 0 ) ;
781-
844+
782845 const summary = [
783846 "" ,
784847 `Total: ${ totalFiles } files, ${ totalDirs } directories` ,
785848 `Combined size: ${ formatSize ( totalSize ) } `
786849 ] ;
787-
850+
788851 return {
789- content : [ {
790- type : "text" ,
791- text : [ ...formattedEntries , ...summary ] . join ( "\n" )
852+ content : [ {
853+ type : "text" ,
854+ text : [ ...formattedEntries , ...summary ] . join ( "\n" )
792855 } ] ,
793856 } ;
794857 }
0 commit comments