feat(files): add chunked upload tools (start/chunk/finish)

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<string, ChunkedUploadSession>, not persistent (server expires 24h)
This commit is contained in:
2026-05-11 17:05:04 +02:00
parent dff7db507d
commit ade15a6f6d
+183 -2
View File
@@ -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<string, ChunkedUploadSession>();
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<ToolResponse> {
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<ToolResponse> {
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<ToolResponse> {
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(