From e92215fd3e7e92b56a4e2f10e960aa2f57496771 Mon Sep 17 00:00:00 2001 From: bea Date: Mon, 11 May 2026 15:04:45 +0200 Subject: [PATCH] feat(tools): implement Browsing & Discovery tools (Step 2) Add 5 file management tools to src/tools/files.ts: - list_files: PROPFIND with depth control (0/1/infinity) - get_file_info: PROPFIND Depth:0 with extended properties (owner, preview, checksums) - search_files: WebDAV SEARCH with PROPFIND fallback + client-side filtering - list_favorites: REPORT with oc:filter-files - get_quota: PROPFIND for quota-used-bytes / quota-available-bytes search_files tries WebDAV SEARCH first; if the server lacks Full Text Search, falls back to PROPFIND Depth:infinity with client-side filtering by name, mimeType (wildcard), size range, date range, and favorite status. Sorting and limit are applied after filtering. --- src/tools/files.ts | 344 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 336 insertions(+), 8 deletions(-) diff --git a/src/tools/files.ts b/src/tools/files.ts index 3605eb9..ad9a7a0 100644 --- a/src/tools/files.ts +++ b/src/tools/files.ts @@ -1,16 +1,344 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { NextcloudClient } from "../client.js"; import { ToolModule } from "./index.js"; -import { ToolResponse } from "../types.js"; +import { FileMetadata, ToolResponse, SearchOptions } from "../types.js"; +import { + buildPropfindBody, + buildPropfindExtendedBody, + buildFavoriteFilterBody, + buildQuotaBody, + buildSearchRequest, + parsePropfindFilesResponse, + parsePropfindSingleFileResponse, + parseSearchResponse, + parseQuotaResponse, +} from "../webdav.js"; +import { normalizePath, buildDavPath, makeToolResponse, makeErrorResponse } from "../utils.js"; -// File management tools — scaffolding, will be populated in subsequent steps export const filesToolModule: ToolModule = { - definitions: [], + definitions: [ + { + name: "list_files", + description: + "List files and folders in a Nextcloud directory. Returns metadata (name, path, type, size, mimeType, lastModified, etc.) for each entry.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: 'Directory path relative to user root (default: "/")', + default: "/", + }, + depth: { + type: "string", + enum: ["0", "1", "infinity"], + description: 'PROPFIND depth — "1" lists immediate children (default), "0" is the folder itself, "infinity" is recursive', + default: "1", + }, + }, + }, + }, + { + name: "get_file_info", + description: + "Get detailed metadata for a single file or folder, including owner, preview availability, and checksums.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "File or folder path relative to user root", + }, + }, + required: ["path"], + }, + }, + { + name: "search_files", + description: + "Search for files by name, MIME type, size, modification date, or favorite status. Uses WebDAV SEARCH when available, falls back to PROPFIND with client-side filtering.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: 'Scope directory for the search (default: "/" — entire user root)', + }, + query: { + type: "string", + description: "Filename pattern (matches prefix, case-insensitive)", + }, + mimeType: { + type: "string", + description: 'MIME type filter, exact or wildcard (e.g. "image/*")', + }, + minSize: { + type: "number", + description: "Minimum file size in bytes", + }, + maxSize: { + type: "number", + description: "Maximum file size in bytes", + }, + modifiedAfter: { + type: "string", + description: "Only files modified after this ISO 8601 date", + }, + modifiedBefore: { + type: "string", + description: "Only files modified before this ISO 8601 date", + }, + favorite: { + type: "boolean", + description: "If true, only return favorite files", + }, + sortBy: { + type: "string", + enum: ["name", "size", "lastModified"], + description: "Sort results by this field", + }, + sortOrder: { + type: "string", + enum: ["asc", "desc"], + description: 'Sort direction (default: "asc")', + }, + limit: { + type: "number", + description: "Maximum number of results (default: 50)", + }, + }, + }, + }, + { + name: "list_favorites", + description: "List all files and folders marked as favorites in Nextcloud.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: 'Scope directory (default: "/" — search entire user root)', + }, + }, + }, + }, + { + name: "get_quota", + description: "Get the current storage quota — used space, available space, and total.", + inputSchema: { + type: "object", + properties: {}, + }, + }, + ], - async handler(_name, _args, _client): Promise { - return { - content: [{ type: "text", text: "File tools not yet implemented." }], - isError: true, - }; + async handler(name, args, client): Promise { + try { + switch (name) { + case "list_files": + return await handleListFiles(args, client); + case "get_file_info": + return await handleGetFileInfo(args, client); + case "search_files": + return await handleSearchFiles(args, client); + case "list_favorites": + return await handleListFavorites(args, client); + case "get_quota": + return await handleGetQuota(args, client); + default: + return makeErrorResponse(`Unknown file tool: ${name}`); + } + } catch (error: any) { + return makeErrorResponse(error.message || String(error)); + } }, }; + +// --- Handlers --- + +async function handleListFiles( + args: { path?: string; depth?: string }, + client: NextcloudClient +): Promise { + const path = normalizePath(args.path || "/"); + const depth = args.depth || "1"; + const davPath = buildDavPath(client.username, path); + + const xml = await client.propfind(davPath, buildPropfindBody(), depth); + + // For depth "1", parsePropfindFilesResponse already excludes the root folder. + // For depth "0", we want the single item — use parsePropfindSingleFileResponse. + // For depth "infinity", parsePropfindFilesResponse excludes root and returns all descendants. + if (depth === "0") { + const meta = parsePropfindSingleFileResponse(xml, davPath); + return makeToolResponse(meta ? [meta] : []); + } + + const files = parsePropfindFilesResponse(xml, davPath); + return makeToolResponse(files); +} + +async function handleGetFileInfo( + 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); + + const xml = await client.propfind(davPath, buildPropfindExtendedBody(), "0"); + const meta = parsePropfindSingleFileResponse(xml, davPath); + + if (!meta) return makeErrorResponse(`File not found: ${path}`); + return makeToolResponse(meta); +} + +async function handleSearchFiles( + args: SearchOptions, + client: NextcloudClient +): Promise { + const searchOpts: SearchOptions = { + path: args.path || "/", + query: args.query, + mimeType: args.mimeType, + minSize: args.minSize, + maxSize: args.maxSize, + modifiedAfter: args.modifiedAfter, + modifiedBefore: args.modifiedBefore, + favorite: args.favorite, + sortBy: args.sortBy, + sortOrder: args.sortOrder, + limit: args.limit ?? 50, + }; + + // Try WebDAV SEARCH first; if it fails (e.g. no Full Text Search app), fall back to PROPFIND + try { + const searchBody = buildSearchRequest(searchOpts, client.username); + const xml = await client.search(searchBody); + let results = parseSearchResponse(xml, `/remote.php/dav/files/${client.username}`); + results = applyClientSideFilters(results, searchOpts); + results = applySorting(results, searchOpts); + if (searchOpts.limit && results.length > searchOpts.limit) { + results = results.slice(0, searchOpts.limit); + } + return makeToolResponse(results); + } catch { + // Fallback: PROPFIND Depth:infinity + client-side filtering + return await searchViaPropfindFallback(searchOpts, client); + } +} + +/** Fallback search: PROPFIND Depth:infinity with client-side filtering. */ +async function searchViaPropfindFallback( + opts: SearchOptions, + client: NextcloudClient +): Promise { + const path = normalizePath(opts.path || "/"); + const davPath = buildDavPath(client.username, path); + + // Use standard propfind body (includes all needed props for filtering) + const xml = await client.propfind(davPath, buildPropfindBody(), "infinity"); + let results = parsePropfindFilesResponse(xml, davPath); + + // Client-side filtering + results = applyClientSideFilters(results, opts); + results = applySorting(results, opts); + + if (opts.limit && results.length > opts.limit) { + results = results.slice(0, opts.limit); + } + + return makeToolResponse(results); +} + +/** Apply client-side filters to FileMetadata array based on SearchOptions. */ +function applyClientSideFilters( + files: FileMetadata[], + opts: SearchOptions +): FileMetadata[] { + let result = files; + + if (opts.query) { + const q = opts.query.toLowerCase(); + result = result.filter((f) => f.name.toLowerCase().includes(q)); + } + + if (opts.mimeType) { + const pattern = opts.mimeType.replace(/\*/g, ".*"); + const regex = new RegExp(`^${pattern}$`, "i"); + result = result.filter((f) => f.mimeType && regex.test(f.mimeType)); + } + + if (opts.minSize !== undefined) { + result = result.filter((f) => (f.size ?? 0) >= opts.minSize!); + } + + if (opts.maxSize !== undefined) { + result = result.filter((f) => (f.size ?? 0) <= opts.maxSize!); + } + + if (opts.modifiedAfter) { + const after = new Date(opts.modifiedAfter).getTime(); + result = result.filter((f) => f.lastModified && new Date(f.lastModified).getTime() >= after); + } + + if (opts.modifiedBefore) { + const before = new Date(opts.modifiedBefore).getTime(); + result = result.filter((f) => f.lastModified && new Date(f.lastModified).getTime() <= before); + } + + if (opts.favorite === true) { + result = result.filter((f) => f.favorite); + } + + return result; +} + +/** Apply sorting to FileMetadata array. */ +function applySorting( + files: FileMetadata[], + opts: SearchOptions +): FileMetadata[] { + if (!opts.sortBy) return files; + + const dir = opts.sortOrder === "desc" ? -1 : 1; + + return [...files].sort((a, b) => { + switch (opts.sortBy) { + case "name": + return dir * a.name.localeCompare(b.name); + case "size": + return dir * ((a.size ?? 0) - (b.size ?? 0)); + case "lastModified": + return dir * (new Date(a.lastModified || 0).getTime() - new Date(b.lastModified || 0).getTime()); + default: + return 0; + } + }); +} + +async function handleListFavorites( + args: { path?: string }, + client: NextcloudClient +): Promise { + const path = normalizePath(args.path || "/"); + const davPath = buildDavPath(client.username, path); + + const xml = await client.report(davPath, buildFavoriteFilterBody()); + const files = parsePropfindFilesResponse(xml, davPath); + return makeToolResponse(files); +} + +async function handleGetQuota( + _args: Record, + client: NextcloudClient +): Promise { + // PROPFIND Depth:0 on the user's root with quota properties + const rootPath = `/remote.php/dav/files/${client.username}`; + const quotaBody = buildQuotaBody(); + + const xml = await client.propfind(rootPath, quotaBody, "0"); + const quota = parseQuotaResponse(xml); + return makeToolResponse(quota); +}