read-tools.ts
1 import { createReadStream } from "fs"; 2 import path from "path"; 3 import { zodToJsonSchema } from "zod-to-json-schema"; 4 import { ToolSchema } from "@modelcontextprotocol/sdk/types.js"; 5 import { 6 ReadFileArgsSchema, 7 AttachImageArgsSchema, 8 ReadMultipleFilesArgsSchema, 9 ReadFileRequestSchema, 10 type ReadFileArgs, 11 type AttachImageArgs, 12 type ReadMultipleFilesArgs, 13 type ReadFileRequest, 14 } from "../types/index.js"; 15 import { 16 validatePath, 17 readFileContent, 18 tailFile, 19 headFile, 20 rangeFile, 21 } from "../utils/lib.js"; 22 import { isDocumentFile, parseDocument } from "../utils/document-parser.js"; 23 import { 24 createPathArraySchema, 25 sanitizeToolInputSchema, 26 } from "../utils/tool-schema.js"; 27 28 const ToolInputSchema = ToolSchema.shape.inputSchema; 29 type ToolInput = any; 30 31 // Reads a file as a stream of buffers, concatenates them, and then encodes 32 // the result to a Base64 string. This is a memory-efficient way to handle 33 // binary data from a stream before the final encoding. 34 async function readFileAsBase64Stream(filePath: string): Promise<string> { 35 return new Promise((resolve, reject) => { 36 const stream = createReadStream(filePath); 37 const chunks: Buffer[] = []; 38 stream.on("data", (chunk) => { 39 chunks.push(chunk as Buffer); 40 }); 41 stream.on("end", () => { 42 const finalBuffer = Buffer.concat(chunks); 43 resolve(finalBuffer.toString("base64")); 44 }); 45 stream.on("error", (err) => reject(err)); 46 }); 47 } 48 49 export function getReadTools() { 50 return [ 51 { 52 name: "read_file", 53 description: 54 "Read files with flexible modes. Supports text and documents " + 55 "(PDF, DOCX, PPTX, XLSX, ODT, ODP, ODS). " + 56 "PDF: Extracts with format metadata (fonts, colors, layout). " + 57 "Modes: full (entire file), head (first N lines), tail (last N lines), " + 58 "range (lines from startLine to endLine, inclusive, 1-indexed). " + 59 "Document files ignore mode parameters and always return full content. " + 60 "Only works within allowed directories.", 61 inputSchema: sanitizeToolInputSchema( 62 zodToJsonSchema(ReadFileArgsSchema) as ToolInput 63 ), 64 }, 65 { 66 name: "attach_image", 67 description: 68 "Attach an image file for AI vision analysis. The image will be presented " + 69 "to the AI model as if uploaded directly by the user, enabling the AI to see " + 70 "and describe visual content, read text in images, analyze diagrams, etc. " + 71 "Supports attaching a single image or multiple images at once. " + 72 "Supports PNG, JPEG, GIF, WebP, BMP, and SVG formats. " + 73 "Note: This requires the MCP client to support vision capabilities. " + 74 "Only works within allowed directories.", 75 inputSchema: { 76 type: "object", 77 properties: { 78 path: createPathArraySchema( 79 "Path(s) to image file(s) to attach for AI vision analysis. For maximum MCP client compatibility, provide an array even when attaching a single image." 80 ), 81 }, 82 required: ["path"], 83 additionalProperties: false, 84 } as ToolInput, 85 }, 86 { 87 name: "read_multiple_files", 88 description: 89 "Batch read multiple files concurrently with per-file mode control. " + 90 "Supports text and documents (PDF, DOCX, PPTX, XLSX, ODT, ODP, ODS). " + 91 "Each file can specify its own read mode: full, head (first N lines), " + 92 "tail (last N lines), or range (arbitrary line range). " + 93 "Document files ignore mode parameters and return full content. " + 94 "Processes files concurrently for performance. Maximum 50 files per operation. " + 95 "Only works within allowed directories.", 96 inputSchema: sanitizeToolInputSchema( 97 zodToJsonSchema(ReadMultipleFilesArgsSchema) as ToolInput 98 ), 99 }, 100 ]; 101 } 102 103 export async function handleReadTool(name: string, args: any) { 104 switch (name) { 105 case "read_file": { 106 const parsed = ReadFileArgsSchema.safeParse(args); 107 if (!parsed.success) { 108 throw new Error(`Invalid arguments for read_file: ${parsed.error}`); 109 } 110 111 const validPath = await validatePath(parsed.data.path); 112 113 // Check if document file (automatic detection) 114 if (isDocumentFile(validPath)) { 115 // Parse document (ignores mode/lines parameters for documents) 116 const result = await parseDocument(validPath); 117 118 let output = result.text; 119 120 // Add metadata header if available 121 if (result.metadata) { 122 const meta = result.metadata; 123 let header = `Document: ${path.basename(validPath)}\n`; 124 if (meta.format) header += `Format: ${meta.format}\n`; 125 if (meta.pages) header += `Pages: ${meta.pages}\n`; 126 if (meta.author) header += `Author: ${meta.author}\n`; 127 if (meta.title) header += `Title: ${meta.title}\n`; 128 129 output = header + "\n" + output; 130 } 131 132 return { 133 content: [{ type: "text", text: output }], 134 }; 135 } 136 137 // Regular text file handling (existing logic) 138 const mode = parsed.data.mode || "full"; 139 140 switch (mode) { 141 case "tail": { 142 const tailContent = await tailFile(validPath, parsed.data.lines!); 143 return { 144 content: [{ type: "text", text: tailContent }], 145 }; 146 } 147 148 case "head": { 149 const headContent = await headFile(validPath, parsed.data.lines!); 150 return { 151 content: [{ type: "text", text: headContent }], 152 }; 153 } 154 155 case "range": { 156 const rangeContent = await rangeFile( 157 validPath, 158 parsed.data.startLine!, 159 parsed.data.endLine! 160 ); 161 return { 162 content: [{ type: "text", text: rangeContent }], 163 }; 164 } 165 166 case "full": 167 default: { 168 const content = await readFileContent(validPath); 169 return { 170 content: [{ type: "text", text: content }], 171 }; 172 } 173 } 174 } 175 176 case "attach_image": { 177 const parsed = AttachImageArgsSchema.safeParse(args); 178 if (!parsed.success) { 179 throw new Error(`Invalid arguments for attach_image: ${parsed.error}`); 180 } 181 182 // Support both single path and array of paths 183 const paths = Array.isArray(parsed.data.path) 184 ? parsed.data.path 185 : [parsed.data.path]; 186 187 // Supported image formats only (no audio) 188 const mimeTypes: Record<string, string> = { 189 ".png": "image/png", 190 ".jpg": "image/jpeg", 191 ".jpeg": "image/jpeg", 192 ".gif": "image/gif", 193 ".webp": "image/webp", 194 ".bmp": "image/bmp", 195 ".svg": "image/svg+xml", 196 }; 197 198 // Process all images 199 const imageContents = await Promise.all( 200 paths.map(async (imagePath) => { 201 const validPath = await validatePath(imagePath); 202 const extension = path.extname(validPath).toLowerCase(); 203 const mimeType = mimeTypes[extension]; 204 205 if (!mimeType) { 206 throw new Error( 207 `Unsupported image format: ${extension}. ` + 208 `Supported formats: PNG, JPEG, GIF, WebP, BMP, SVG` 209 ); 210 } 211 212 const data = await readFileAsBase64Stream(validPath); 213 214 return { 215 type: "image" as const, 216 data: data, 217 mimeType: mimeType, 218 }; 219 }) 220 ); 221 222 // Return all images in MCP-compliant format 223 return { 224 content: imageContents, 225 }; 226 } 227 228 case "read_multiple_files": { 229 const parsed = ReadMultipleFilesArgsSchema.safeParse(args); 230 if (!parsed.success) { 231 throw new Error( 232 `Invalid arguments for read_multiple_files: ${parsed.error}` 233 ); 234 } 235 236 // Helper function to read a single file with mode support 237 async function readSingleFile( 238 fileRequest: ReadFileRequest 239 ): Promise<string> { 240 try { 241 const validPath = await validatePath(fileRequest.path); 242 243 // Check if document file (documents ignore mode parameters) 244 if (isDocumentFile(validPath)) { 245 const result = await parseDocument(validPath); 246 247 let output = `${fileRequest.path}:\n`; 248 if (result.metadata?.format) { 249 output += `Format: ${result.metadata.format}\n`; 250 } 251 252 // Add formatting information if available 253 if (result.formatting) { 254 if ( 255 result.formatting.fonts && 256 result.formatting.fonts.length > 0 257 ) { 258 output += `Fonts: ${result.formatting.fonts 259 .map((f) => f.name) 260 .join(", ")}\n`; 261 } 262 if ( 263 result.formatting.colors && 264 result.formatting.colors.length > 0 265 ) { 266 output += `Colors: ${result.formatting.colors.join(", ")}\n`; 267 } 268 if (result.formatting.layout?.pages) { 269 output += `Layout: ${ 270 result.formatting.layout.pages.length 271 } page(s) with ${result.formatting.layout.pages.reduce( 272 (sum, p) => sum + p.texts.length, 273 0 274 )} positioned elements\n`; 275 } 276 } 277 278 output += result.text + "\n"; 279 return output; 280 } 281 282 // Handle text files with mode support 283 const mode = fileRequest.mode || "full"; 284 let content: string; 285 286 switch (mode) { 287 case "tail": 288 content = await tailFile(validPath, fileRequest.lines!); 289 break; 290 case "head": 291 content = await headFile(validPath, fileRequest.lines!); 292 break; 293 case "range": 294 content = await rangeFile( 295 validPath, 296 fileRequest.startLine!, 297 fileRequest.endLine! 298 ); 299 break; 300 case "full": 301 default: 302 content = await readFileContent(validPath); 303 break; 304 } 305 306 return `${fileRequest.path}:\n${content}\n`; 307 } catch (error) { 308 const errorMessage = 309 error instanceof Error ? error.message : String(error); 310 return `${fileRequest.path}: Error - ${errorMessage}`; 311 } 312 } 313 314 // Process all files concurrently 315 const results = await Promise.all(parsed.data.files.map(readSingleFile)); 316 317 return { 318 content: [{ type: "text", text: results.join("\n---\n") }], 319 }; 320 } 321 322 default: 323 throw new Error(`Unknown read tool: ${name}`); 324 } 325 }