feat(files): Step 4 — add upload_file, create_folder, bulk_upload tools

- 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
This commit is contained in:
2026-05-11 17:01:25 +02:00
parent 46e9ca752d
commit dff7db507d
+230 -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 } 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<ToolResponse> {
@@ -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<ToolResponse> {
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<string, string> = {
"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<ToolResponse> {
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<ToolResponse> {
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);
}