From 46e9ca752dcab4cd29b4ddb4dbc7c80a02623b48 Mon Sep 17 00:00:00 2001 From: bea Date: Mon, 11 May 2026 16:53:15 +0200 Subject: [PATCH] Step 3: Add read_file, download_file, download_folder tools - read_file: GET file content inline (utf8 for text, base64 for binary) with configurable maxSize (default 10MB) to stay within MCP transport limits - download_file: returns direct download URL for out-of-band fetching with optional HEAD metadata - download_folder: GET folder as ZIP/TAR archive, returns inline base64 if under maxSize (default 50MB), otherwise returns download URL with optional X-NC-Files header for selective download --- src/tools/files.ts | 263 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 262 insertions(+), 1 deletion(-) diff --git a/src/tools/files.ts b/src/tools/files.ts index ad9a7a0..35809af 100644 --- a/src/tools/files.ts +++ b/src/tools/files.ts @@ -13,7 +13,7 @@ import { parseSearchResponse, parseQuotaResponse, } from "../webdav.js"; -import { normalizePath, buildDavPath, makeToolResponse, makeErrorResponse } from "../utils.js"; +import { normalizePath, buildDavPath, buildDavUrl, makeToolResponse, makeErrorResponse, isTextMimeType, detectMimeType } from "../utils.js"; export const filesToolModule: ToolModule = { definitions: [ @@ -130,6 +130,83 @@ export const filesToolModule: ToolModule = { properties: {}, }, }, + { + name: "read_file", + description: + "Read the content of a file. Returns inline content (UTF-8 text or base64 for binary). " + + "For files larger than maxSize (default 10MB), returns an error suggesting download_file instead. " + + "The size limit is a transport constraint (MCP JSON over stdio), not a Nextcloud limit.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "File path relative to user root", + }, + encoding: { + type: "string", + enum: ["utf8", "base64"], + description: 'Output encoding. Default: "utf8" (auto-switches to base64 for binary files)', + }, + maxSize: { + type: "number", + description: "Maximum file size in bytes (default: 10485760 = 10MB). Files exceeding this return an error.", + }, + }, + required: ["path"], + }, + }, + { + name: "download_file", + description: + "Get a direct download URL for a file. Does NOT return file content — returns a URL that can be used " + + "with curl, wget, or a browser to download the file out-of-band. Useful for large files that exceed " + + "MCP transport limits.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "File path relative to user root", + }, + metadata: { + type: "boolean", + description: "If true, also fetch file metadata via HEAD request (default: true)", + }, + }, + required: ["path"], + }, + }, + { + name: "download_folder", + description: + "Download a folder as a ZIP or TAR archive. For folders under maxSize (default 50MB), returns base64-encoded " + + "content inline. For larger folders, returns a direct download URL with query parameters.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "Folder path relative to user root", + }, + format: { + type: "string", + enum: ["zip", "tar"], + description: 'Archive format (default: "zip")', + }, + files: { + type: "array", + items: { type: "string" }, + description: "Optional list of filenames to include (relative to the folder). If omitted, includes all files.", + }, + maxSize: { + type: "number", + description: "Maximum archive size in bytes (default: 52428800 = 50MB). Larger archives return a download URL.", + }, + }, + required: ["path"], + }, + }, ], async handler(name, args, client): Promise { @@ -145,6 +222,12 @@ export const filesToolModule: ToolModule = { return await handleListFavorites(args, client); case "get_quota": return await handleGetQuota(args, client); + case "read_file": + return await handleReadFile(args, client); + case "download_file": + return await handleDownloadFile(args, client); + case "download_folder": + return await handleDownloadFolder(args, client); default: return makeErrorResponse(`Unknown file tool: ${name}`); } @@ -342,3 +425,181 @@ async function handleGetQuota( const quota = parseQuotaResponse(xml); return makeToolResponse(quota); } + +// --- Step 3: Read & Download --- + +const DEFAULT_READ_MAX_SIZE = 10 * 1024 * 1024; // 10MB +const DEFAULT_FOLDER_MAX_SIZE = 50 * 1024 * 1024; // 50MB + +async function handleReadFile( + args: { path: string; encoding?: string; maxSize?: number }, + client: NextcloudClient +): Promise { + if (!args.path) return makeErrorResponse("path is required"); + + const path = normalizePath(args.path); + const davPath = buildDavPath(client.username, path); + const maxSize = args.maxSize ?? DEFAULT_READ_MAX_SIZE; + const requestedEncoding = args.encoding ?? "utf8"; + + // Fetch as arraybuffer to handle both text and binary + const resp = await client.get(davPath, {}, "arraybuffer"); + const data: Buffer = resp.data; + const contentLength = data.length; + + // Check size + if (contentLength > maxSize) { + return makeErrorResponse( + `File too large (${contentLength} bytes, limit ${maxSize} bytes). ` + + `Use download_file to get a direct download URL.` + ); + } + + // Determine content type from response headers + const contentType: string | undefined = resp.headers?.["content-type"]; + const isText = isTextMimeType(contentType); + + let content: string; + let encoding: string; + + if (requestedEncoding === "base64" || !isText) { + // Binary or explicitly requested base64 + content = data.toString("base64"); + encoding = "base64"; + } else { + // Text content + content = data.toString("utf-8"); + encoding = "utf8"; + } + + return makeToolResponse({ + path, + size: contentLength, + mimeType: contentType, + encoding, + content, + }); +} + +async function handleDownloadFile( + args: { path: string; metadata?: boolean }, + client: NextcloudClient +): Promise { + if (!args.path) return makeErrorResponse("path is required"); + + const path = normalizePath(args.path); + const davPath = buildDavPath(client.username, path); + const downloadUrl = `${client.baseUrl}${davPath}`; + + const fetchMetadata = args.metadata !== false; // default true + let meta: FileMetadata | undefined; + + if (fetchMetadata) { + try { + const xml = await client.propfind(davPath, buildPropfindExtendedBody(), "0"); + meta = parsePropfindSingleFileResponse(xml, davPath) ?? undefined; + } catch { + // If PROPFIND fails, still return the URL + } + } + + return makeToolResponse({ + ...(meta ? { metadata: meta } : { path }), + downloadUrl, + }); +} + +async function handleDownloadFolder( + args: { path: string; format?: string; files?: string[]; maxSize?: number }, + client: NextcloudClient +): Promise { + if (!args.path) return makeErrorResponse("path is required"); + + const path = normalizePath(args.path); + const davPath = buildDavPath(client.username, path); + const format = args.format ?? "zip"; + const maxSize = args.maxSize ?? DEFAULT_FOLDER_MAX_SIZE; + const acceptType = `application/${format}`; + + // Validate that the path is a folder + try { + const xml = await client.propfind(davPath, buildPropfindBody(), "0"); + const info = parsePropfindSingleFileResponse(xml, davPath); + if (!info || info.type !== "folder") { + return makeErrorResponse(`Path is not a folder: ${path}`); + } + } catch (err: any) { + return makeErrorResponse(`Cannot access folder: ${err.message || String(err)}`); + } + + // Build request headers + const headers: Record = { + Accept: acceptType, + }; + + // If specific files are requested, use X-NC-Files header + if (args.files && args.files.length > 0) { + headers["X-NC-Files"] = JSON.stringify(args.files); + } + + // Try to fetch the archive + try { + const resp = await client.get(davPath, headers, "arraybuffer"); + const data: Buffer = resp.data; + const contentLength = data.length; + + // Build the base result + const baseMeta = { + path, + format, + fileCount: args.files?.length ?? undefined, + totalSize: contentLength, + }; + + // If too large, return URL instead of content + if (contentLength > maxSize) { + const downloadUrl = buildFolderDownloadUrl(client, path, format, args.files); + return makeToolResponse({ + metadata: baseMeta, + downloadUrl, + note: `Archive is ${contentLength} bytes (limit ${maxSize}). Use the URL to download directly.`, + }); + } + + // Return inline as base64 + return makeToolResponse({ + metadata: baseMeta, + content: data.toString("base64"), + encoding: "base64", + }); + } catch (err: any) { + // If the server doesn't support ZIP/TAR download, return URL + const downloadUrl = buildFolderDownloadUrl(client, path, format, args.files); + return makeToolResponse({ + metadata: { path, format }, + downloadUrl, + note: "Could not fetch archive inline. Use the URL to download directly.", + }); + } +} + +/** Build a direct download URL for a folder archive with optional file filter. */ +function buildFolderDownloadUrl( + client: NextcloudClient, + path: string, + format: string, + files?: string[] +): string { + const davPath = buildDavPath(client.username, normalizePath(path)); + let url = `${client.baseUrl}${davPath}`; + + const params: string[] = [`accept=${format}`]; + if (files && files.length > 0) { + params.push(`files=${encodeURIComponent(JSON.stringify(files))}`); + } + if (params.length > 0) { + url += `?${params.join("&")}`; + } + + return url; +}