/ src / tools / read-tools.ts
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  }