Merge pull request #2382 from cliffhall/main

Add media file reading in filesystem server
This commit is contained in:
Ola Hungerford
2025-07-26 07:59:50 -07:00
committed by GitHub
4 changed files with 125 additions and 46 deletions

9
package-lock.json generated
View File

@@ -6159,7 +6159,7 @@
"version": "0.6.2", "version": "0.6.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.3", "@modelcontextprotocol/sdk": "^1.17.0",
"diff": "^5.1.0", "diff": "^5.1.0",
"glob": "^10.3.10", "glob": "^10.3.10",
"minimatch": "^10.0.1", "minimatch": "^10.0.1",
@@ -6182,9 +6182,9 @@
} }
}, },
"src/filesystem/node_modules/@modelcontextprotocol/sdk": { "src/filesystem/node_modules/@modelcontextprotocol/sdk": {
"version": "1.12.3", "version": "1.17.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.3.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.0.tgz",
"integrity": "sha512-DyVYSOafBvk3/j1Oka4z5BWT8o4AFmoNyZY9pALOm7Lh3GZglR71Co4r4dEUoqDWdDazIZQHBe7J2Nwkg6gHgQ==", "integrity": "sha512-qFfbWFA7r1Sd8D697L7GkTd36yqDuTkvz0KfOGkgXR8EUhQn3/EDNIR/qUdQNMT8IjmasBvHWuXeisxtXTQT2g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ajv": "^6.12.6", "ajv": "^6.12.6",
@@ -6192,6 +6192,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.5", "cross-spawn": "^7.0.5",
"eventsource": "^3.0.2", "eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.0.1", "express": "^5.0.1",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"pkce-challenge": "^5.0.0", "pkce-challenge": "^5.0.0",

View File

@@ -70,10 +70,19 @@ The server's directory access control follows this flow:
### Tools ### Tools
- **read_file** - **read_text_file**
- Read complete contents of a file - Read complete contents of a file as text
- Input: `path` (string) - Inputs:
- Reads complete file contents with UTF-8 encoding - `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
- Inputs:
- `path` (string)
- Streams the file and returns base64 data with the corresponding MIME type
- **read_multiple_files** - **read_multiple_files**
- Read multiple files simultaneously - Read multiple files simultaneously

View File

@@ -10,6 +10,7 @@ import {
type Root, type Root,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import fs from "fs/promises"; import fs from "fs/promises";
import { createReadStream } from "fs";
import path from "path"; import path from "path";
import os from 'os'; import os from 'os';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
@@ -116,12 +117,16 @@ async function validatePath(requestedPath: string): Promise<string> {
} }
// Schema definitions // Schema definitions
const ReadFileArgsSchema = z.object({ const ReadTextFileArgsSchema = z.object({
path: z.string(), path: z.string(),
tail: z.number().optional().describe('If provided, returns only the last N lines of the file'), 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') 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({ const ReadMultipleFilesArgsSchema = z.object({
paths: z.array(z.string()), paths: z.array(z.string()),
}); });
@@ -471,20 +476,51 @@ async function headFile(filePath: string, numLines: number): Promise<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<string> {
return new Promise((resolve, reject) => {
const stream = createReadStream(filePath);
const chunks: Buffer[] = [];
stream.on('data', (chunk) => {
chunks.push(chunk as Buffer);
});
stream.on('end', () => {
const finalBuffer = Buffer.concat(chunks);
resolve(finalBuffer.toString('base64'));
});
stream.on('error', (err) => reject(err));
});
}
// Tool handlers // Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
return { return {
tools: [ tools: [
{ {
name: "read_file", name: "read_file",
description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.",
inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput,
},
{
name: "read_text_file",
description: 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 " + "Handles various text encodings and provides detailed error messages " +
"if the file cannot be read. Use this tool when you need to examine " + "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 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 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.", "the last N lines of a file. Operates on the file as text regardless of extension. " +
inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput, "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", name: "read_multiple_files",
@@ -597,10 +633,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params; const { name, arguments: args } = request.params;
switch (name) { switch (name) {
case "read_file": { case "read_file":
const parsed = ReadFileArgsSchema.safeParse(args); case "read_text_file": {
const parsed = ReadTextFileArgsSchema.safeParse(args);
if (!parsed.success) { 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); const validPath = await validatePath(parsed.data.path);
@@ -630,6 +667,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<string, string> = {
".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 readFileAsBase64Stream(validPath);
const type = mimeType.startsWith("image/")
? "image"
: mimeType.startsWith("audio/")
? "audio"
: "blob";
return {
content: [{ type, data, mimeType }],
};
}
case "read_multiple_files": { case "read_multiple_files": {
const parsed = ReadMultipleFilesArgsSchema.safeParse(args); const parsed = ReadMultipleFilesArgsSchema.safeParse(args);
if (!parsed.success) { if (!parsed.success) {

View File

@@ -20,7 +20,7 @@
"test": "jest --config=jest.config.cjs --coverage" "test": "jest --config=jest.config.cjs --coverage"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.3", "@modelcontextprotocol/sdk": "^1.17.0",
"diff": "^5.1.0", "diff": "^5.1.0",
"glob": "^10.3.10", "glob": "^10.3.10",
"minimatch": "^10.0.1", "minimatch": "^10.0.1",