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:
+230
-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 } from "../types.js";
|
import { FileMetadata, ToolResponse, SearchOptions, BulkUploadResult } from "../types.js";
|
||||||
import {
|
import {
|
||||||
buildPropfindBody,
|
buildPropfindBody,
|
||||||
buildPropfindExtendedBody,
|
buildPropfindExtendedBody,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
parseSearchResponse,
|
parseSearchResponse,
|
||||||
parseQuotaResponse,
|
parseQuotaResponse,
|
||||||
} from "../webdav.js";
|
} 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 = {
|
export const filesToolModule: ToolModule = {
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -207,6 +207,82 @@ export const filesToolModule: ToolModule = {
|
|||||||
required: ["path"],
|
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> {
|
async handler(name, args, client): Promise<ToolResponse> {
|
||||||
@@ -228,6 +304,12 @@ export const filesToolModule: ToolModule = {
|
|||||||
return await handleDownloadFile(args, client);
|
return await handleDownloadFile(args, client);
|
||||||
case "download_folder":
|
case "download_folder":
|
||||||
return await handleDownloadFolder(args, client);
|
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:
|
default:
|
||||||
return makeErrorResponse(`Unknown file tool: ${name}`);
|
return makeErrorResponse(`Unknown file tool: ${name}`);
|
||||||
}
|
}
|
||||||
@@ -603,3 +685,149 @@ function buildFolderDownloadUrl(
|
|||||||
|
|
||||||
return url;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user