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,
|
parseSearchResponse,
|
||||||
parseQuotaResponse,
|
parseQuotaResponse,
|
||||||
} from "../webdav.js";
|
} 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 = {
|
export const filesToolModule: ToolModule = {
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -130,6 +130,83 @@ export const filesToolModule: ToolModule = {
|
|||||||
properties: {},
|
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> {
|
async handler(name, args, client): Promise<ToolResponse> {
|
||||||
@@ -145,6 +222,12 @@ export const filesToolModule: ToolModule = {
|
|||||||
return await handleListFavorites(args, client);
|
return await handleListFavorites(args, client);
|
||||||
case "get_quota":
|
case "get_quota":
|
||||||
return await handleGetQuota(args, client);
|
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:
|
default:
|
||||||
return makeErrorResponse(`Unknown file tool: ${name}`);
|
return makeErrorResponse(`Unknown file tool: ${name}`);
|
||||||
}
|
}
|
||||||
@@ -342,3 +425,181 @@ async function handleGetQuota(
|
|||||||
const quota = parseQuotaResponse(xml);
|
const quota = parseQuotaResponse(xml);
|
||||||
return makeToolResponse(quota);
|
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