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
This commit is contained in:
2026-05-11 16:53:15 +02:00
parent e92215fd3e
commit 46e9ca752d
+262 -1
View File
@@ -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<ToolResponse> {
@@ -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<ToolResponse> {
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<ToolResponse> {
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<ToolResponse> {
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<string, string> = {
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;
}