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.
This commit is contained in:
2026-05-11 15:04:45 +02:00
parent ce477546a1
commit e92215fd3e
+336 -8
View File
@@ -1,16 +1,344 @@
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 { 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 = { 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<ToolResponse> { async handler(name, args, client): Promise<ToolResponse> {
return { try {
content: [{ type: "text", text: "File tools not yet implemented." }], switch (name) {
isError: true, 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<ToolResponse> {
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<ToolResponse> {
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<ToolResponse> {
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<ToolResponse> {
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<ToolResponse> {
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<string, never>,
client: NextcloudClient
): Promise<ToolResponse> {
// 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);
}