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 { 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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user