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:
+371
-1
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user