From dff7db507d78f9e05a30e5f40eb15ea0fdff6d5b Mon Sep 17 00:00:00 2001 From: bea Date: Mon, 11 May 2026 17:01:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(files):=20Step=204=20=E2=80=94=20add=20upl?= =?UTF-8?q?oad=5Ffile,=20create=5Ffolder,=20bulk=5Fupload=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upload_file: PUT request with utf8/base64 encoding, auto MIME detection, X-OC-Mtime support - create_folder: MKCOL request with 405 (already exists) error handling - bulk_upload: POST multipart/related with manual body construction for multi-file upload - All tools return FileMetadata/BulkUploadResult via PROPFIND after operation --- src/tools/files.ts | 232 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 230 insertions(+), 2 deletions(-) diff --git a/src/tools/files.ts b/src/tools/files.ts index 35809af..4442901 100644 --- a/src/tools/files.ts +++ b/src/tools/files.ts @@ -1,7 +1,7 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { NextcloudClient } from "../client.js"; import { ToolModule } from "./index.js"; -import { FileMetadata, ToolResponse, SearchOptions } from "../types.js"; +import { FileMetadata, ToolResponse, SearchOptions, BulkUploadResult } from "../types.js"; import { buildPropfindBody, buildPropfindExtendedBody, @@ -13,7 +13,7 @@ import { parseSearchResponse, parseQuotaResponse, } from "../webdav.js"; -import { normalizePath, buildDavPath, buildDavUrl, makeToolResponse, makeErrorResponse, isTextMimeType, detectMimeType } from "../utils.js"; +import { normalizePath, buildDavPath, buildDavUrl, makeToolResponse, makeErrorResponse, isTextMimeType, detectMimeType, generateUUID } from "../utils.js"; export const filesToolModule: ToolModule = { definitions: [ @@ -207,6 +207,82 @@ export const filesToolModule: ToolModule = { required: ["path"], }, }, + { + name: "upload_file", + description: + "Upload a file to Nextcloud via PUT request. For files up to ~10MB of content in the param. " + + "Supports utf8 or base64 encoding. Returns file metadata after upload.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "Full file path relative to user root (including filename)", + }, + content: { + type: "string", + description: "File content (utf8 string or base64-encoded)", + }, + encoding: { + type: "string", + enum: ["utf8", "base64"], + description: 'Content encoding (default: "utf8")', + }, + contentType: { + type: "string", + description: "MIME type. Auto-detected from filename if not provided.", + }, + mtime: { + type: "number", + description: "Modification time as unix timestamp. Sets X-OC-Mtime header.", + }, + }, + required: ["path", "content"], + }, + }, + { + name: "create_folder", + description: + "Create a new folder in Nextcloud. Returns folder metadata. " + + "Returns an error if the folder already exists.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "Folder path relative to user root", + }, + }, + required: ["path"], + }, + }, + { + name: "bulk_upload", + description: + "Upload multiple small files in a single request using multipart/related. " + + "Each file has its own path, content, encoding, contentType, and optional mtime.", + inputSchema: { + type: "object", + properties: { + files: { + type: "array", + items: { + type: "object", + properties: { + path: { type: "string", description: "File path relative to user root" }, + content: { type: "string", description: "File content (utf8 or base64)" }, + encoding: { type: "string", enum: ["utf8", "base64"], description: 'Content encoding (default: "utf8")' }, + contentType: { type: "string", description: "MIME type" }, + mtime: { type: "number", description: "Modification time as unix timestamp" }, + }, + required: ["path", "content"], + }, + description: "Array of files to upload", + }, + }, + required: ["files"], + }, + }, ], async handler(name, args, client): Promise { @@ -228,6 +304,12 @@ export const filesToolModule: ToolModule = { return await handleDownloadFile(args, client); case "download_folder": return await handleDownloadFolder(args, client); + case "upload_file": + return await handleUploadFile(args, client); + case "create_folder": + return await handleCreateFolder(args, client); + case "bulk_upload": + return await handleBulkUpload(args, client); default: return makeErrorResponse(`Unknown file tool: ${name}`); } @@ -603,3 +685,149 @@ function buildFolderDownloadUrl( return url; } + +// --- Step 4: Write & Upload --- + +async function handleUploadFile( + args: { path: string; content: string; encoding?: string; contentType?: string; mtime?: number }, + client: NextcloudClient +): Promise { + if (!args.path) return makeErrorResponse("path is required"); + if (args.content === undefined || args.content === null) return makeErrorResponse("content is required"); + + const path = normalizePath(args.path); + const davPath = buildDavPath(client.username, path); + + // Decode content + let body: Buffer | string; + if (args.encoding === "base64") { + body = Buffer.from(args.content, "base64"); + } else { + body = args.content; + } + + // Determine Content-Type + const contentType = args.contentType || detectMimeType(path); + + // Build headers + const headers: Record = { + "Content-Type": contentType, + }; + if (args.mtime !== undefined) { + headers["X-OC-Mtime"] = String(args.mtime); + } + + // PUT the file + await client.put(davPath, body, headers); + + // PROPFIND Depth:0 to return metadata of the uploaded file + const xml = await client.propfind(davPath, buildPropfindBody(), "0"); + const meta = parsePropfindSingleFileResponse(xml, davPath); + + return makeToolResponse(meta ?? { path, success: true }); +} + +async function handleCreateFolder( + args: { path: string }, + client: NextcloudClient +): Promise { + if (!args.path) return makeErrorResponse("path is required"); + + const path = normalizePath(args.path); + const davPath = buildDavPath(client.username, path); + + try { + await client.mkcol(davPath); + } catch (err: any) { + const status = err?.response?.status; + if (status === 405) { + return makeErrorResponse(`Folder already exists: ${path}`); + } + throw err; + } + + // PROPFIND Depth:0 to return metadata of the created folder + const xml = await client.propfind(davPath, buildPropfindBody(), "0"); + const meta = parsePropfindSingleFileResponse(xml, davPath); + + return makeToolResponse(meta ?? { path, type: "folder" }); +} + +async function handleBulkUpload( + args: { files: Array<{ path: string; content: string; encoding?: string; contentType?: string; mtime?: number }> }, + client: NextcloudClient +): Promise { + if (!args.files || !Array.isArray(args.files) || args.files.length === 0) { + return makeErrorResponse("files array is required and must not be empty"); + } + + const boundary = `----NextcloudMCPBoundary${generateUUID()}`; + const parts: Buffer[] = []; + + for (const file of args.files) { + if (!file.path || file.content === undefined) { + return makeErrorResponse(`Each file must have 'path' and 'content'. Missing in: ${JSON.stringify(file.path)}`); + } + + // Decode content + let rawContent: Buffer; + if (file.encoding === "base64") { + rawContent = Buffer.from(file.content, "base64"); + } else { + rawContent = Buffer.from(file.content, "utf-8"); + } + + const contentType = file.contentType || detectMimeType(file.path); + + // Build part headers + let partHeaders = `X-File-Path: /remote.php/dav/files/${client.username}${normalizePath(file.path)}\r\n`; + partHeaders += `Content-Length: ${rawContent.length}\r\n`; + partHeaders += `Content-Type: ${contentType}\r\n`; + if (file.mtime !== undefined) { + partHeaders += `X-OC-Mtime: ${file.mtime}\r\n`; + } + + // Build the part: boundary + headers + empty line + content + const partHeader = Buffer.from(`--${boundary}\r\n${partHeaders}\r\n`, "utf-8"); + parts.push(partHeader); + parts.push(rawContent); + parts.push(Buffer.from("\r\n", "utf-8")); + } + + // Final boundary + parts.push(Buffer.from(`--${boundary}--\r\n`, "utf-8")); + + // Concatenate all parts + const body = Buffer.concat(parts); + + const contentType = `multipart/related; boundary=${boundary}`; + + // POST to bulk endpoint + const resp = await client.postBulk(body, contentType); + + // Parse response + let results: BulkUploadResult[]; + try { + const respData = typeof resp.data === "string" ? JSON.parse(resp.data) : resp.data; + results = Object.entries(respData).map(([filePath, info]: [string, any]) => { + // filePath is like /remote.php/dav/files/user/path — extract relative path + const davBase = `/remote.php/dav/files/${client.username}`; + const relativePath = filePath.startsWith(davBase) ? filePath.slice(davBase.length) : filePath; + + return { + path: relativePath || filePath, + error: info.error === true, + etag: info.etag || undefined, + errorMessage: info.error === true ? (info.message || "Upload failed") : undefined, + }; + }); + } catch { + // If parsing fails, return the raw response + return makeToolResponse({ + raw: typeof resp.data === "string" ? resp.data : String(resp.data), + note: "Could not parse bulk upload response as JSON", + }); + } + + return makeToolResponse(results); +}