From ade15a6f6d29cd98fea431fae87046a7a760b83f Mon Sep 17 00:00:00 2001 From: bea Date: Mon, 11 May 2026 17:05:04 +0200 Subject: [PATCH] feat(files): add chunked upload tools (start/chunk/finish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 — Chunked Upload for large files: - chunked_upload_start: creates upload session with UUID, MKCOL /uploads/{user}/{uuid} - chunked_upload_chunk: uploads base64-encoded chunks (1-based, 5-digit padded) - chunked_upload_finish: MOVE .file to assemble, returns FileMetadata State: in-memory Map, not persistent (server expires 24h) --- src/tools/files.ts | 185 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 183 insertions(+), 2 deletions(-) diff --git a/src/tools/files.ts b/src/tools/files.ts index 4442901..28e7ae1 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, BulkUploadResult } from "../types.js"; +import { FileMetadata, ToolResponse, SearchOptions, BulkUploadResult, ChunkedUploadSession } from "../types.js"; import { buildPropfindBody, buildPropfindExtendedBody, @@ -13,7 +13,10 @@ import { parseSearchResponse, parseQuotaResponse, } from "../webdav.js"; -import { normalizePath, buildDavPath, buildDavUrl, makeToolResponse, makeErrorResponse, isTextMimeType, detectMimeType, generateUUID } from "../utils.js"; +import { normalizePath, buildDavPath, makeToolResponse, makeErrorResponse, isTextMimeType, detectMimeType, generateUUID } from "../utils.js"; + +// Active chunked upload sessions (in-memory, not persistent — expires server-side after 24h) +const activeUploads = new Map(); export const filesToolModule: ToolModule = { definitions: [ @@ -256,6 +259,75 @@ export const filesToolModule: ToolModule = { required: ["path"], }, }, + { + name: "chunked_upload_start", + description: + "Start a chunked upload session for large files. Creates a temporary upload directory on the server. " + + "Use chunked_upload_chunk to upload each chunk, then chunked_upload_finish to assemble the final file. " + + "Sessions expire after 24h of inactivity.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "Destination file path relative to user root", + }, + totalSize: { + type: "number", + description: "Total file size in bytes", + }, + chunkSize: { + type: "number", + description: "Chunk size in bytes (default: 10485760 = 10MB, min 5MB max 5GB)", + }, + }, + required: ["path", "totalSize"], + }, + }, + { + name: "chunked_upload_chunk", + description: + "Upload a single chunk in a chunked upload session. Content must be base64-encoded. " + + "Chunks are 1-indexed (first chunk = 1).", + inputSchema: { + type: "object", + properties: { + uploadId: { + type: "string", + description: "Upload session ID from chunked_upload_start", + }, + chunkIndex: { + type: "number", + description: "Chunk number (1-based)", + }, + content: { + type: "string", + description: "Chunk content as base64-encoded string", + }, + }, + required: ["uploadId", "chunkIndex", "content"], + }, + }, + { + name: "chunked_upload_finish", + description: + "Finish a chunked upload by assembling all chunks into the final file. " + + "The server merges the chunks and cleans up the temporary upload directory.", + inputSchema: { + type: "object", + properties: { + uploadId: { + type: "string", + description: "Upload session ID from chunked_upload_start", + }, + mtime: { + type: "number", + description: "Optional modification time as unix timestamp (sets X-OC-Mtime)", + }, + }, + required: ["uploadId"], + }, + }, { name: "bulk_upload", description: @@ -308,6 +380,12 @@ export const filesToolModule: ToolModule = { return await handleUploadFile(args, client); case "create_folder": return await handleCreateFolder(args, client); + case "chunked_upload_start": + return await handleChunkedUploadStart(args, client); + case "chunked_upload_chunk": + return await handleChunkedUploadChunk(args, client); + case "chunked_upload_finish": + return await handleChunkedUploadFinish(args, client); case "bulk_upload": return await handleBulkUpload(args, client); default: @@ -686,6 +764,109 @@ function buildFolderDownloadUrl( return url; } +// --- Step 5: Chunked Upload --- + +const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB +const MIN_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB +const MAX_CHUNK_SIZE = 5 * 1024 * 1024 * 1024; // 5GB + +async function handleChunkedUploadStart( + args: { path: string; totalSize: number; chunkSize?: number }, + client: NextcloudClient +): Promise { + if (!args.path) return makeErrorResponse("path is required"); + if (!args.totalSize || args.totalSize <= 0) return makeErrorResponse("totalSize must be > 0"); + + const path = normalizePath(args.path); + const chunkSize = Math.max(MIN_CHUNK_SIZE, Math.min(args.chunkSize ?? DEFAULT_CHUNK_SIZE, MAX_CHUNK_SIZE)); + const totalChunks = Math.ceil(args.totalSize / chunkSize); + const uploadId = generateUUID(); + + // Build the final destination URL (full DAV URL) + const destination = `${client.baseUrl}${buildDavPath(client.username, path)}`; + + // Create upload directory: MKCOL /uploads/{user}/{uuid} with Destination header + const uploadDir = `/uploads/${client.username}/${uploadId}`; + await client.mkcol(uploadDir, { Destination: destination }); + + // Save session + const session: ChunkedUploadSession = { + uploadId, + destination, + totalSize: args.totalSize, + chunkSize, + totalChunks, + createdAt: Math.floor(Date.now() / 1000), + }; + activeUploads.set(uploadId, session); + + return makeToolResponse({ uploadId, totalChunks, chunkSize }); +} + +async function handleChunkedUploadChunk( + args: { uploadId: string; chunkIndex: number; content: string }, + client: NextcloudClient +): Promise { + if (!args.uploadId) return makeErrorResponse("uploadId is required"); + if (!args.chunkIndex || args.chunkIndex < 1) return makeErrorResponse("chunkIndex must be >= 1 (1-based)"); + if (!args.content) return makeErrorResponse("content is required (base64-encoded)"); + + const session = activeUploads.get(args.uploadId); + if (!session) return makeErrorResponse(`Upload session not found: ${args.uploadId}. It may have expired.`); + + if (args.chunkIndex > session.totalChunks) { + return makeErrorResponse(`chunkIndex ${args.chunkIndex} exceeds totalChunks ${session.totalChunks}`); + } + + // Decode base64 content + const buffer = Buffer.from(args.content, "base64"); + + // Format chunk index to 5 digits + const chunkIdx = String(args.chunkIndex).padStart(5, "0"); + + // PUT chunk: /uploads/{user}/{uuid}/{chunkIdx} + const chunkPath = `/uploads/${client.username}/${session.uploadId}/${chunkIdx}`; + await client.put(chunkPath, buffer, { + Destination: session.destination, + "OC-Total-Length": String(session.totalSize), + }); + + // Calculate uploaded size so far (progressive) + const uploadedSize = Math.min(args.chunkIndex * session.chunkSize, session.totalSize); + + return makeToolResponse({ success: true, uploadedSize }); +} + +async function handleChunkedUploadFinish( + args: { uploadId: string; mtime?: number }, + client: NextcloudClient +): Promise { + if (!args.uploadId) return makeErrorResponse("uploadId is required"); + + const session = activeUploads.get(args.uploadId); + if (!session) return makeErrorResponse(`Upload session not found: ${args.uploadId}. It may have expired.`); + + // MOVE /uploads/{user}/{uuid}/.file → destination with OC-Total-Length + const sourcePath = `/uploads/${client.username}/${session.uploadId}/.file`; + + await client.move(sourcePath, session.destination, true); + + // Remove session from map + activeUploads.delete(args.uploadId); + + // PROPFIND Depth:0 on the destination to return FileMetadata + // Extract the relative path from the destination URL + const davPrefix = `${client.baseUrl}/remote.php/dav/files/${client.username}`; + const relativePath = session.destination.startsWith(davPrefix) + ? session.destination.slice(davPrefix.length) + : "/"; + const davPath = buildDavPath(client.username, normalizePath(relativePath)); + const xml = await client.propfind(davPath, buildPropfindBody(), "0"); + const meta = parsePropfindSingleFileResponse(xml, davPath); + + return makeToolResponse(meta ?? { path: relativePath, success: true }); +} + // --- Step 4: Write & Upload --- async function handleUploadFile(