feat(files): add trashbin tools (Step 7) — trash_list, trash_restore, trash_delete, trash_empty

Also includes uncommitted changes from parallel subagents (Steps 6, 8).
This commit is contained in:
2026-05-11 17:09:39 +02:00
parent ade15a6f6d
commit 66112abe9c
+371 -1
View File
@@ -1,17 +1,22 @@
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, ChunkedUploadSession } from "../types.js"; import { FileMetadata, TrashedFile, FileVersion, ToolResponse, SearchOptions, BulkUploadResult, ChunkedUploadSession } from "../types.js";
import { import {
buildPropfindBody, buildPropfindBody,
buildPropfindExtendedBody, buildPropfindExtendedBody,
buildFavoriteFilterBody, buildFavoriteFilterBody,
buildQuotaBody, buildQuotaBody,
buildSearchRequest, buildSearchRequest,
buildTrashbinPropfindBody,
buildProppatchBody,
buildVersionsPropfindBody,
parsePropfindFilesResponse, parsePropfindFilesResponse,
parsePropfindSingleFileResponse, parsePropfindSingleFileResponse,
parseSearchResponse, parseSearchResponse,
parseQuotaResponse, parseQuotaResponse,
parseTrashbinResponse,
parseVersionsResponse,
} from "../webdav.js"; } from "../webdav.js";
import { normalizePath, buildDavPath, makeToolResponse, makeErrorResponse, isTextMimeType, detectMimeType, generateUUID } from "../utils.js"; import { normalizePath, buildDavPath, makeToolResponse, makeErrorResponse, isTextMimeType, detectMimeType, generateUUID } from "../utils.js";
@@ -355,6 +360,178 @@ export const filesToolModule: ToolModule = {
required: ["files"], required: ["files"],
}, },
}, },
{
name: "move_file",
description:
"Move or rename a file/folder in Nextcloud. Uses MOVE request with a Destination header. " +
"Returns metadata of the file at its new location. " +
"Also works for renaming (change the filename in the destination path).",
inputSchema: {
type: "object",
properties: {
source: {
type: "string",
description: "Current file/folder path relative to user root",
},
destination: {
type: "string",
description: "New file/folder path relative to user root",
},
overwrite: {
type: "boolean",
description: 'If true, overwrite existing file at destination (default: false)',
},
},
required: ["source", "destination"],
},
},
{
name: "copy_file",
description:
"Copy a file/folder in Nextcloud. Uses COPY request with a Destination header. " +
"Returns metadata of the new copy.",
inputSchema: {
type: "object",
properties: {
source: {
type: "string",
description: "Source file/folder path relative to user root",
},
destination: {
type: "string",
description: "Destination file/folder path relative to user root",
},
overwrite: {
type: "boolean",
description: 'If true, overwrite existing file at destination (default: false)',
},
},
required: ["source", "destination"],
},
},
{
name: "delete_file",
description:
"Delete a file or folder in Nextcloud. The file is moved to the trashbin (not permanently deleted). " +
"Use trash_list and trash_restore to recover deleted files.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "File or folder path relative to user root",
},
},
required: ["path"],
},
},
{
name: "trash_list",
description:
"List all items in the Nextcloud trashbin (deleted files and folders). " +
"Returns metadata including original name, original location, and deletion time.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "trash_restore",
description:
"Restore a deleted file or folder from the trashbin to its original location. " +
"The trashPath should be the path as returned by trash_list.",
inputSchema: {
type: "object",
properties: {
trashPath: {
type: "string",
description: "Path of the item in the trashbin (as returned by trash_list)",
},
},
required: ["trashPath"],
},
},
{
name: "trash_delete",
description:
"Permanently delete an item from the trashbin. This cannot be undone.",
inputSchema: {
type: "object",
properties: {
trashPath: {
type: "string",
description: "Path of the item in the trashbin (as returned by trash_list)",
},
},
required: ["trashPath"],
},
},
{
name: "trash_empty",
description:
"Empty the entire trashbin. All permanently deleted items will be unrecoverable. " +
"This action cannot be undone.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "set_favorite",
description:
"Mark or unmark a file or folder as favorite in Nextcloud. " +
"Returns updated file metadata after the change.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "File or folder path relative to user root",
},
favorite: {
type: "boolean",
description: "true to mark as favorite, false to remove favorite",
},
},
required: ["path", "favorite"],
},
},
{
name: "get_file_versions",
description:
"List all stored versions of a file, identified by its numeric file ID (oc:fileid). " +
"Returns version name, size, last modified date, and etag for each version.",
inputSchema: {
type: "object",
properties: {
fileId: {
type: "number",
description: "Numeric file ID (oc:fileid) of the file",
},
},
required: ["fileId"],
},
},
{
name: "restore_file_version",
description:
"Restore a specific version of a file, identified by file ID and version name (timestamp). " +
"The version name comes from get_file_versions.",
inputSchema: {
type: "object",
properties: {
fileId: {
type: "number",
description: "Numeric file ID (oc:fileid) of the file",
},
versionName: {
type: "string",
description: "Version identifier (timestamp string) from get_file_versions",
},
},
required: ["fileId", "versionName"],
},
},
], ],
async handler(name, args, client): Promise<ToolResponse> { async handler(name, args, client): Promise<ToolResponse> {
@@ -388,6 +565,26 @@ export const filesToolModule: ToolModule = {
return await handleChunkedUploadFinish(args, client); return await handleChunkedUploadFinish(args, client);
case "bulk_upload": case "bulk_upload":
return await handleBulkUpload(args, client); return await handleBulkUpload(args, client);
case "move_file":
return await handleMoveFile(args, client);
case "copy_file":
return await handleCopyFile(args, client);
case "delete_file":
return await handleDeleteFile(args, client);
case "trash_list":
return await handleTrashList(args, client);
case "trash_restore":
return await handleTrashRestore(args, client);
case "trash_delete":
return await handleTrashDelete(args, client);
case "trash_empty":
return await handleTrashEmpty(args, client);
case "set_favorite":
return await handleSetFavorite(args, client);
case "get_file_versions":
return await handleGetFileVersions(args, client);
case "restore_file_version":
return await handleRestoreFileVersion(args, client);
default: default:
return makeErrorResponse(`Unknown file tool: ${name}`); return makeErrorResponse(`Unknown file tool: ${name}`);
} }
@@ -1012,3 +1209,176 @@ async function handleBulkUpload(
return makeToolResponse(results); return makeToolResponse(results);
} }
// --- Step 6: Move, Copy, Delete ---
async function handleMoveFile(
args: { source: string; destination: string; overwrite?: boolean },
client: NextcloudClient
): Promise<ToolResponse> {
if (!args.source) return makeErrorResponse("source is required");
if (!args.destination) return makeErrorResponse("destination is required");
const sourcePath = normalizePath(args.source);
const destPath = normalizePath(args.destination);
const sourceDavPath = buildDavPath(client.username, sourcePath);
const destDavPath = buildDavPath(client.username, destPath);
const destUrl = `${client.baseUrl}${destDavPath}`;
try {
await client.move(sourceDavPath, destUrl, args.overwrite ?? false);
} catch (err: any) {
const status = err?.response?.status;
if (status === 412) {
return makeErrorResponse(
`Destination already exists: ${destPath}. Use overwrite=true to replace it.`
);
}
if (status === 404) {
return makeErrorResponse(`Source not found: ${sourcePath}`);
}
throw err;
}
// PROPFIND Depth:0 on destination for metadata
const xml = await client.propfind(destDavPath, buildPropfindBody(), "0");
const meta = parsePropfindSingleFileResponse(xml, destDavPath);
return makeToolResponse(meta ?? { source: sourcePath, destination: destPath, success: true });
}
async function handleCopyFile(
args: { source: string; destination: string; overwrite?: boolean },
client: NextcloudClient
): Promise<ToolResponse> {
if (!args.source) return makeErrorResponse("source is required");
if (!args.destination) return makeErrorResponse("destination is required");
const sourcePath = normalizePath(args.source);
const destPath = normalizePath(args.destination);
const sourceDavPath = buildDavPath(client.username, sourcePath);
const destDavPath = buildDavPath(client.username, destPath);
const destUrl = `${client.baseUrl}${destDavPath}`;
try {
await client.copy(sourceDavPath, destUrl, args.overwrite ?? false);
} catch (err: any) {
const status = err?.response?.status;
if (status === 412) {
return makeErrorResponse(
`Destination already exists: ${destPath}. Use overwrite=true to replace it.`
);
}
if (status === 404) {
return makeErrorResponse(`Source not found: ${sourcePath}`);
}
throw err;
}
// PROPFIND Depth:0 on destination for metadata
const xml = await client.propfind(destDavPath, buildPropfindBody(), "0");
const meta = parsePropfindSingleFileResponse(xml, destDavPath);
return makeToolResponse(meta ?? { source: sourcePath, destination: destPath, success: true });
}
async function handleDeleteFile(
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.delete(davPath);
} catch (err: any) {
const status = err?.response?.status;
if (status === 404) {
return makeErrorResponse(`File not found: ${path}`);
}
if (status === 403) {
return makeErrorResponse(`Permission denied: cannot delete ${path}`);
}
throw err;
}
return makeToolResponse({ success: true, path });
}
// --- Step 7: Trashbin ---
async function handleTrashList(
_args: Record<string, never>,
client: NextcloudClient
): Promise<ToolResponse> {
const trashPath = `/trashbin/${client.username}/trash`;
const xml = await client.propfind(trashPath, buildTrashbinPropfindBody(), "1");
const items = parseTrashbinResponse(xml);
return makeToolResponse(items);
}
async function handleTrashRestore(
args: { trashPath: string },
client: NextcloudClient
): Promise<ToolResponse> {
if (!args.trashPath) return makeErrorResponse("trashPath is required");
// trashPath is the item path in the trashbin, e.g. "/Documents/old-file.txt.d12345678"
// It comes from trash_list results — the path field of a TrashedFile.
const itemPath = `/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`;
const restoreUrl = `${client.baseUrl}/trashbin/${client.username}/restore`;
try {
await client.move(itemPath, restoreUrl);
} catch (err: any) {
const status = err?.response?.status;
if (status === 404) {
return makeErrorResponse(`Trashbin item not found: ${args.trashPath}. It may have already been permanently deleted.`);
}
throw err;
}
return makeToolResponse({ success: true, restoredPath: args.trashPath });
}
async function handleTrashDelete(
args: { trashPath: string },
client: NextcloudClient
): Promise<ToolResponse> {
if (!args.trashPath) return makeErrorResponse("trashPath is required");
const itemPath = `/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`;
try {
await client.delete(itemPath);
} catch (err: any) {
const status = err?.response?.status;
if (status === 404) {
return makeErrorResponse(`Trashbin item not found: ${args.trashPath}`);
}
throw err;
}
return makeToolResponse({ success: true });
}
async function handleTrashEmpty(
_args: Record<string, never>,
client: NextcloudClient
): Promise<ToolResponse> {
const trashPath = `/trashbin/${client.username}/trash`;
try {
await client.delete(trashPath);
} catch (err: any) {
const status = err?.response?.status;
if (status === 404) {
return makeErrorResponse("Trashbin is already empty");
}
throw err;
}
return makeToolResponse({ success: true });
}