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 { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { NextcloudClient } from "../client.js";
|
import { NextcloudClient } from "../client.js";
|
||||||
import { ToolModule } from "./index.js";
|
import { ToolModule } from "./index.js";
|
||||||
import { FileMetadata, ToolResponse, SearchOptions, BulkUploadResult } from "../types.js";
|
import { FileMetadata, ToolResponse, SearchOptions, BulkUploadResult, ChunkedUploadSession } from "../types.js";
|
||||||
import {
|
import {
|
||||||
buildPropfindBody,
|
buildPropfindBody,
|
||||||
buildPropfindExtendedBody,
|
buildPropfindExtendedBody,
|
||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
parseSearchResponse,
|
parseSearchResponse,
|
||||||
parseQuotaResponse,
|
parseQuotaResponse,
|
||||||
} from "../webdav.js";
|
} 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 = {
|
export const filesToolModule: ToolModule = {
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -256,6 +259,75 @@ export const filesToolModule: ToolModule = {
|
|||||||
required: ["path"],
|
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",
|
name: "bulk_upload",
|
||||||
description:
|
description:
|
||||||
@@ -308,6 +380,12 @@ export const filesToolModule: ToolModule = {
|
|||||||
return await handleUploadFile(args, client);
|
return await handleUploadFile(args, client);
|
||||||
case "create_folder":
|
case "create_folder":
|
||||||
return await handleCreateFolder(args, client);
|
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":
|
case "bulk_upload":
|
||||||
return await handleBulkUpload(args, client);
|
return await handleBulkUpload(args, client);
|
||||||
default:
|
default:
|
||||||
@@ -686,6 +764,109 @@ function buildFolderDownloadUrl(
|
|||||||
return url;
|
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 ---
|
// --- Step 4: Write & Upload ---
|
||||||
|
|
||||||
async function handleUploadFile(
|
async function handleUploadFile(
|
||||||
|
|||||||
Reference in New Issue
Block a user