From 11a064c359d44e4197694ba4dde72f6fe79ad101 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Fri, 18 Jul 2025 13:42:56 -0400 Subject: [PATCH 1/4] Rename read_file to read_text_file and add read_media_file --- src/filesystem/README.md | 14 ++++++++-- src/filesystem/index.ts | 60 ++++++++++++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/filesystem/README.md b/src/filesystem/README.md index cd6d0a9f06..2ec65400a4 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -70,10 +70,18 @@ The server's directory access control follows this flow: ### Tools -- **read_file** - - Read complete contents of a file +- **read_text_file** + - Read complete contents of a file as text + - Inputs: + - `path` (string) + - `head` (number, optional): First N lines + - `tail` (number, optional): Last N lines + - Always treats the file as UTF-8 text regardless of extension + +- **read_media_file** + - Read an image or audio file - Input: `path` (string) - - Reads complete file contents with UTF-8 encoding + - Returns base64 data and MIME type based on the file extension - **read_multiple_files** - Read multiple files simultaneously diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 524c9c2608..3fc39fecbb 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -116,12 +116,16 @@ async function validatePath(requestedPath: string): Promise { } // Schema definitions -const ReadFileArgsSchema = z.object({ +const ReadTextFileArgsSchema = z.object({ path: z.string(), tail: z.number().optional().describe('If provided, returns only the last N lines of the file'), head: z.number().optional().describe('If provided, returns only the first N lines of the file') }); +const ReadMediaFileArgsSchema = z.object({ + path: z.string() +}); + const ReadMultipleFilesArgsSchema = z.object({ paths: z.array(z.string()), }); @@ -476,15 +480,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { - name: "read_file", + name: "read_text_file", description: - "Read the complete contents of a file from the file system. " + + "Read the complete contents of a file from the file system as text. " + "Handles various text encodings and provides detailed error messages " + "if the file cannot be read. Use this tool when you need to examine " + "the contents of a single file. Use the 'head' parameter to read only " + "the first N lines of a file, or the 'tail' parameter to read only " + - "the last N lines of a file. Only works within allowed directories.", - inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput, + "the last N lines of a file. Operates on the file as text regardless of extension. " + + "Only works within allowed directories.", + inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput, + }, + { + name: "read_media_file", + description: + "Read an image or audio file. Returns the base64 encoded data and MIME type. " + + "Only works within allowed directories.", + inputSchema: zodToJsonSchema(ReadMediaFileArgsSchema) as ToolInput, }, { name: "read_multiple_files", @@ -597,10 +609,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { - case "read_file": { - const parsed = ReadFileArgsSchema.safeParse(args); + case "read_text_file": { + const parsed = ReadTextFileArgsSchema.safeParse(args); if (!parsed.success) { - throw new Error(`Invalid arguments for read_file: ${parsed.error}`); + throw new Error(`Invalid arguments for read_text_file: ${parsed.error}`); } const validPath = await validatePath(parsed.data.path); @@ -630,6 +642,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case "read_media_file": { + const parsed = ReadMediaFileArgsSchema.safeParse(args); + if (!parsed.success) { + throw new Error(`Invalid arguments for read_media_file: ${parsed.error}`); + } + const validPath = await validatePath(parsed.data.path); + const extension = path.extname(validPath).toLowerCase(); + const mimeTypes: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".flac": "audio/flac", + }; + const mimeType = mimeTypes[extension] || "application/octet-stream"; + const data = (await fs.readFile(validPath)).toString("base64"); + const type = mimeType.startsWith("image/") + ? "image" + : mimeType.startsWith("audio/") + ? "audio" + : "blob"; + return { + content: [{ type, data, mimeType } as any], + }; + } + case "read_multiple_files": { const parsed = ReadMultipleFilesArgsSchema.safeParse(args); if (!parsed.success) { From d532a5846d3418ae6748ef1a7ae0c333116ff75b Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Fri, 18 Jul 2025 14:18:32 -0400 Subject: [PATCH 2/4] Stream media file reads --- src/filesystem/README.md | 2 +- src/filesystem/index.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 2ec65400a4..5552e39842 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -81,7 +81,7 @@ The server's directory access control follows this flow: - **read_media_file** - Read an image or audio file - Input: `path` (string) - - Returns base64 data and MIME type based on the file extension + - Streams the file and returns base64 data with the corresponding MIME type - **read_multiple_files** - Read multiple files simultaneously diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 3fc39fecbb..f3254bc97e 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -10,6 +10,7 @@ import { type Root, } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs/promises"; +import { createReadStream } from "fs"; import path from "path"; import os from 'os'; import { randomBytes } from 'crypto'; @@ -475,6 +476,21 @@ async function headFile(filePath: string, numLines: number): Promise { } } +// Stream a file and return its Base64 representation without loading the +// entire file into memory at once. Chunks are encoded individually and +// concatenated into the final string. +async function readFileAsBase64Stream(filePath: string): Promise { + return new Promise((resolve, reject) => { + const stream = createReadStream(filePath, { encoding: 'base64' }); + let data = ''; + stream.on('data', (chunk) => { + data += chunk; + }); + stream.on('end', () => resolve(data)); + stream.on('error', (err) => reject(err)); + }); +} + // Tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => { return { @@ -663,7 +679,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { ".flac": "audio/flac", }; const mimeType = mimeTypes[extension] || "application/octet-stream"; - const data = (await fs.readFile(validPath)).toString("base64"); + const data = await readFileAsBase64Stream(validPath); const type = mimeType.startsWith("image/") ? "image" : mimeType.startsWith("audio/") From 2feb7cbaa5feb35af48078c6c4e0fae5605d119b Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Fri, 18 Jul 2025 16:03:22 -0400 Subject: [PATCH 3/4] Update src/filesystem/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/filesystem/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 5552e39842..89bca6908f 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -80,7 +80,8 @@ The server's directory access control follows this flow: - **read_media_file** - Read an image or audio file - - Input: `path` (string) + - Inputs: + - `path` (string) - Streams the file and returns base64 data with the corresponding MIME type - **read_multiple_files** From ba20bd60af827722652e5b82ce6c2819de0ccdba Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 18 Jul 2025 16:09:56 -0400 Subject: [PATCH 4/4] Update the way the stream is concatenated Update the ts sdk --- package-lock.json | 9 +++-- src/filesystem/index.ts | 77 +++++++++++++++++++------------------ src/filesystem/package.json | 4 +- 3 files changed, 47 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index c785a237fe..38a2fe5c79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6159,7 +6159,7 @@ "version": "0.6.2", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.3", + "@modelcontextprotocol/sdk": "^1.16.0", "diff": "^5.1.0", "glob": "^10.3.10", "minimatch": "^10.0.1", @@ -6182,9 +6182,9 @@ } }, "src/filesystem/node_modules/@modelcontextprotocol/sdk": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.3.tgz", - "integrity": "sha512-DyVYSOafBvk3/j1Oka4z5BWT8o4AFmoNyZY9pALOm7Lh3GZglR71Co4r4dEUoqDWdDazIZQHBe7J2Nwkg6gHgQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.16.0.tgz", + "integrity": "sha512-8ofX7gkZcLj9H9rSd50mCgm3SSF8C7XoclxJuLoV0Cz3rEQ1tv9MZRYYvJtm9n1BiEQQMzSmE/w2AEkNacLYfg==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -6192,6 +6192,7 @@ "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index f3254bc97e..01d6c6380d 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -379,10 +379,10 @@ async function applyFileEdits( function formatSize(bytes: number): string { const units = ['B', 'KB', 'MB', 'GB', 'TB']; if (bytes === 0) return '0 B'; - + const i = Math.floor(Math.log(bytes) / Math.log(1024)); if (i === 0) return `${bytes} ${units[i]}`; - + return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`; } @@ -391,9 +391,9 @@ async function tailFile(filePath: string, numLines: number): Promise { const CHUNK_SIZE = 1024; // Read 1KB at a time const stats = await fs.stat(filePath); const fileSize = stats.size; - + if (fileSize === 0) return ''; - + // Open file for reading const fileHandle = await fs.open(filePath, 'r'); try { @@ -402,36 +402,36 @@ async function tailFile(filePath: string, numLines: number): Promise { let chunk = Buffer.alloc(CHUNK_SIZE); let linesFound = 0; let remainingText = ''; - + // Read chunks from the end of the file until we have enough lines while (position > 0 && linesFound < numLines) { const size = Math.min(CHUNK_SIZE, position); position -= size; - + const { bytesRead } = await fileHandle.read(chunk, 0, size, position); if (!bytesRead) break; - + // Get the chunk as a string and prepend any remaining text from previous iteration const readData = chunk.slice(0, bytesRead).toString('utf-8'); const chunkText = readData + remainingText; - + // Split by newlines and count const chunkLines = normalizeLineEndings(chunkText).split('\n'); - + // If this isn't the end of the file, the first line is likely incomplete // Save it to prepend to the next chunk if (position > 0) { remainingText = chunkLines[0]; chunkLines.shift(); // Remove the first (incomplete) line } - + // Add lines to our result (up to the number we need) for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) { lines.unshift(chunkLines[i]); linesFound++; } } - + return lines.join('\n'); } finally { await fileHandle.close(); @@ -446,14 +446,14 @@ async function headFile(filePath: string, numLines: number): Promise { let buffer = ''; let bytesRead = 0; const chunk = Buffer.alloc(1024); // 1KB buffer - + // Read chunks and count lines until we have enough or reach EOF while (lines.length < numLines) { const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead); if (result.bytesRead === 0) break; // End of file bytesRead += result.bytesRead; buffer += chunk.slice(0, result.bytesRead).toString('utf-8'); - + const newLineIndex = buffer.lastIndexOf('\n'); if (newLineIndex !== -1) { const completeLines = buffer.slice(0, newLineIndex).split('\n'); @@ -464,29 +464,32 @@ async function headFile(filePath: string, numLines: number): Promise { } } } - + // If there is leftover content and we still need lines, add it if (buffer.length > 0 && lines.length < numLines) { lines.push(buffer); } - + return lines.join('\n'); } finally { await fileHandle.close(); } } -// Stream a file and return its Base64 representation without loading the -// entire file into memory at once. Chunks are encoded individually and -// concatenated into the final string. +// Reads a file as a stream of buffers, concatenates them, and then encodes +// the result to a Base64 string. This is a memory-efficient way to handle +// binary data from a stream before the final encoding. async function readFileAsBase64Stream(filePath: string): Promise { return new Promise((resolve, reject) => { - const stream = createReadStream(filePath, { encoding: 'base64' }); - let data = ''; + const stream = createReadStream(filePath); + const chunks: Buffer[] = []; stream.on('data', (chunk) => { - data += chunk; + chunks.push(chunk as Buffer); + }); + stream.on('end', () => { + const finalBuffer = Buffer.concat(chunks); + resolve(finalBuffer.toString('base64')); }); - stream.on('end', () => resolve(data)); stream.on('error', (err) => reject(err)); }); } @@ -631,11 +634,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new Error(`Invalid arguments for read_text_file: ${parsed.error}`); } const validPath = await validatePath(parsed.data.path); - + if (parsed.data.head && parsed.data.tail) { throw new Error("Cannot specify both head and tail parameters simultaneously"); } - + if (parsed.data.tail) { // Use memory-efficient tail implementation for large files const tailContent = await tailFile(validPath, parsed.data.tail); @@ -643,7 +646,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [{ type: "text", text: tailContent }], }; } - + if (parsed.data.head) { // Use memory-efficient head implementation for large files const headContent = await headFile(validPath, parsed.data.head); @@ -651,7 +654,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [{ type: "text", text: headContent }], }; } - + const content = await fs.readFile(validPath, "utf-8"); return { content: [{ type: "text", text: content }], @@ -686,7 +689,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { ? "audio" : "blob"; return { - content: [{ type, data, mimeType } as any], + content: [{ type, data, mimeType }], }; } @@ -794,7 +797,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } const validPath = await validatePath(parsed.data.path); const entries = await fs.readdir(validPath, { withFileTypes: true }); - + // Get detailed information for each entry const detailedEntries = await Promise.all( entries.map(async (entry) => { @@ -817,7 +820,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } }) ); - + // Sort entries based on sortBy parameter const sortedEntries = [...detailedEntries].sort((a, b) => { if (parsed.data.sortBy === 'size') { @@ -826,29 +829,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // Default sort by name return a.name.localeCompare(b.name); }); - + // Format the output - const formattedEntries = sortedEntries.map(entry => + const formattedEntries = sortedEntries.map(entry => `${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${ entry.isDirectory ? "" : formatSize(entry.size).padStart(10) }` ); - + // Add summary const totalFiles = detailedEntries.filter(e => !e.isDirectory).length; const totalDirs = detailedEntries.filter(e => e.isDirectory).length; const totalSize = detailedEntries.reduce((sum, entry) => sum + (entry.isDirectory ? 0 : entry.size), 0); - + const summary = [ "", `Total: ${totalFiles} files, ${totalDirs} directories`, `Combined size: ${formatSize(totalSize)}` ]; - + return { - content: [{ - type: "text", - text: [...formattedEntries, ...summary].join("\n") + content: [{ + type: "text", + text: [...formattedEntries, ...summary].join("\n") }], }; } diff --git a/src/filesystem/package.json b/src/filesystem/package.json index 482f0cce79..8176babf6a 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -20,7 +20,7 @@ "test": "jest --config=jest.config.cjs --coverage" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.3", + "@modelcontextprotocol/sdk": "^1.16.0", "diff": "^5.1.0", "glob": "^10.3.10", "minimatch": "^10.0.1", @@ -38,4 +38,4 @@ "ts-node": "^10.9.2", "typescript": "^5.8.2" } -} \ No newline at end of file +}