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:
+183
-2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user