diff --git a/src/tools/files.ts b/src/tools/files.ts index 28e7ae1..d0a28a5 100644 --- a/src/tools/files.ts +++ b/src/tools/files.ts @@ -1,17 +1,22 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { NextcloudClient } from "../client.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 { buildPropfindBody, buildPropfindExtendedBody, buildFavoriteFilterBody, buildQuotaBody, buildSearchRequest, + buildTrashbinPropfindBody, + buildProppatchBody, + buildVersionsPropfindBody, parsePropfindFilesResponse, parsePropfindSingleFileResponse, parseSearchResponse, parseQuotaResponse, + parseTrashbinResponse, + parseVersionsResponse, } from "../webdav.js"; import { normalizePath, buildDavPath, makeToolResponse, makeErrorResponse, isTextMimeType, detectMimeType, generateUUID } from "../utils.js"; @@ -355,6 +360,178 @@ export const filesToolModule: ToolModule = { 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 { @@ -388,6 +565,26 @@ export const filesToolModule: ToolModule = { return await handleChunkedUploadFinish(args, client); case "bulk_upload": 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: return makeErrorResponse(`Unknown file tool: ${name}`); } @@ -1012,3 +1209,176 @@ async function handleBulkUpload( return makeToolResponse(results); } + +// --- Step 6: Move, Copy, Delete --- + +async function handleMoveFile( + args: { source: string; destination: string; overwrite?: boolean }, + client: NextcloudClient +): Promise { + 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 { + 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 { + 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, + client: NextcloudClient +): Promise { + 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 { + 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 { + 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, + client: NextcloudClient +): Promise { + 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 }); +}