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:
+336
-8
@@ -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<ToolResponse> {
|
||||
return {
|
||||
content: [{ type: "text", text: "File tools not yet implemented." }],
|
||||
isError: true,
|
||||
};
|
||||
async handler(name, args, client): Promise<ToolResponse> {
|
||||
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<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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user