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:
+262
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user