Step 10: Code review, testing & documentation

Code Review (REVIEW.md):
- Fixed parseSearchResponse skipping first result (critical bug)
- Fixed trashbin/versions/chunked paths missing /remote.php/dav prefix
- Fixed trash_restore to use original file location instead of /restore endpoint
- Fixed createTask/updateTask missing iCal text escaping
- Added 409 handling for createFolder (parent missing)
- Extracted duplicate decodeXmlText to utils.ts
- Extracted duplicate generateUID to utils.ts (shared with calendar/tasks)
- Removed 5 dead code functions (parseVEVENT, extractVEventBlocks, unfoldICalLines, getCalDAVXmlHeaders, local decodeXmlText)
- Cleaned unused imports across all tool files

Testing (RESULTS.md):
- 35 tests passed, 1 skipped (trash_empty), 1 server limitation (bulk_upload)
- Tested all 21+ file tools, edge cases (spaces, unicode, overwrite, empty folders)
- Verified chunked upload end-to-end

Documentation (README.md):
- Complete tool reference (21 file + 10 other tools)
- Quick start, CLI usage, size limits, troubleshooting
- Architecture overview
This commit is contained in:
2026-05-11 18:05:37 +02:00
parent 2cb1666441
commit 8461970523
9 changed files with 482 additions and 371 deletions
+1 -96
View File
@@ -1,5 +1,6 @@
import { format } from "date-fns";
import ICAL from "ical.js";
import { decodeXmlText } from "./utils.js";
export interface CalendarInfo {
name: string;
@@ -9,14 +10,6 @@ export interface CalendarInfo {
export type DebugLogger = (message: string) => void;
export function getCalDAVXmlHeaders(depth: string = "1"): Record<string, string> {
return {
Accept: "application/xml",
"Content-Type": "application/xml; charset=utf-8",
Depth: depth,
};
}
export function buildTasksReportBody(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
@@ -396,79 +389,6 @@ function parseVTODO(todoData: string): any | null {
}
}
function parseVEVENT(eventData: string): any | null {
try {
const jcalData = ICAL.parse(eventData);
const comp = new ICAL.Component(jcalData);
const vevents = comp.getAllSubcomponents("vevent");
// We expect individual events here, handled iteration in the caller if needed
// However, if the calendarData block has multiple events, ICAL parsing the whole
// object will find them. We'll return the first one here that has a UID.
const vevent = comp.getFirstSubcomponent("vevent");
if (!vevent) return null;
const event: any = {};
event.uid = vevent.getFirstPropertyValue("uid");
if (vevent.hasProperty("summary")) {
event.summary = vevent.getFirstPropertyValue("summary");
}
if (vevent.hasProperty("description")) {
event.description = vevent.getFirstPropertyValue("description");
}
if (vevent.hasProperty("location")) {
event.location = vevent.getFirstPropertyValue("location");
}
if (vevent.hasProperty("status")) {
event.status = vevent.getFirstPropertyValue("status");
}
if (vevent.hasProperty("dtstart")) {
const startProp = vevent.getFirstProperty("dtstart");
event.startRaw = startProp?.getFirstValue()?.toString();
event.start = event.startRaw;
}
if (vevent.hasProperty("dtend")) {
const endProp = vevent.getFirstProperty("dtend");
event.end = endProp?.getFirstValue()?.toString();
}
if (vevent.hasProperty("rrule")) {
const rrule = vevent.getFirstPropertyValue("rrule");
event.rrule = rrule?.toString();
}
const alarms = vevent.getAllSubcomponents("valarm");
if (alarms && alarms.length > 0) {
event.alarms = alarms.map((alarm: any) => ({
action: alarm.getFirstPropertyValue("action"),
trigger: alarm.getFirstPropertyValue("trigger") ? alarm.getFirstProperty("trigger").getFirstValue().toString() : null,
description: alarm.getFirstPropertyValue("description")
}));
}
return event.uid ? event : null;
} catch (error) {
return null;
}
}
function extractVEventBlocks(calendarData: string): string[] {
// ICAL.js does not need string splitting, it parses the whole VCALENDAR.
// We'll leave this to split generic strings if needed by the old logic,
// but better yet, let's just parse the full calendarData string directly in the caller.
return Array.from(
calendarData.matchAll(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g),
(match) => `BEGIN:VCALENDAR\nVERSION:2.0\n${match[0]}\nEND:VCALENDAR`
);
}
function unfoldICalLines(data: string): string[] {
// Normalize CR artifacts and unfold folded iCalendar lines.
const normalized = data.replace(/\r/g, "").replace(/\n[ \t]/g, "");
return normalized.split(/\n/);
}
function normalizeCalendarHref(href: string): string {
let normalized = href.trim();
if (!normalized) {
@@ -493,21 +413,6 @@ function normalizeCalendarHref(href: string): string {
return normalized;
}
function decodeXmlText(value: string): string {
return value
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) =>
String.fromCodePoint(parseInt(hex, 16))
)
.replace(/&#([0-9]+);/g, (_m, dec) =>
String.fromCodePoint(parseInt(dec, 10))
)
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}
function parseICalToDate(icalDate: string): Date | null {
const match = icalDate.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/);
if (!match) {
+1 -15
View File
@@ -9,7 +9,6 @@ import {
dedupeEvents,
formatICalDate,
formatICalDateTimeUtc,
getCalDAVXmlHeaders,
getEventSortTimestamp,
parseCalendarsFromPROPFIND,
parseEventsFromCalDAV,
@@ -18,6 +17,7 @@ import {
stripEventInternalFields,
} from "../caldav.js";
import { format } from "date-fns";
import { escapeICalText, generateUUID as generateUID } from "../utils.js";
export const calendarToolModule: ToolModule = {
definitions: [
@@ -373,20 +373,6 @@ END:VCALENDAR`;
// --- Helpers ---
function generateUID(): string {
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
}
function escapeICalText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/\n/g, "\\n")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,");
}
const debugEnabled = process.env.DEBUG_NEXTCLOUD_MCP === "1";
function debugLog(client: NextcloudClient, message: string): void {
+32 -17
View File
@@ -1,7 +1,7 @@
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { NextcloudClient } from "../client.js";
import { ToolModule } from "./index.js";
import { FileMetadata, TrashedFile, FileVersion, ToolResponse, SearchOptions, BulkUploadResult, ChunkedUploadSession } from "../types.js";
import { FileMetadata, FileVersion, ToolResponse, SearchOptions, BulkUploadResult, ChunkedUploadSession } from "../types.js";
import {
buildPropfindBody,
buildPropfindExtendedBody,
@@ -800,6 +800,7 @@ async function handleReadFile(
const requestedEncoding = args.encoding ?? "utf8";
// Fetch as arraybuffer to handle both text and binary
// For files >maxSize, download_file is the recommended alternative
const resp = await client.get(davPath, {}, "arraybuffer");
const data: Buffer = resp.data;
const contentLength = data.length;
@@ -982,8 +983,8 @@ async function handleChunkedUploadStart(
// Build the final destination URL (full DAV URL)
const destination = `${client.baseUrl}${buildDavPath(client.username, path)}`;
// Create upload directory: MKCOL /uploads/{user}/{uuid} with Destination header
const uploadDir = `/uploads/${client.username}/${uploadId}`;
// Create upload directory: MKCOL /remote.php/dav/uploads/{user}/{uuid} with Destination header
const uploadDir = `/remote.php/dav/uploads/${client.username}/${uploadId}`;
await client.mkcol(uploadDir, { Destination: destination });
// Save session
@@ -1021,8 +1022,8 @@ async function handleChunkedUploadChunk(
// Format chunk index to 5 digits
const chunkIdx = String(args.chunkIndex).padStart(5, "0");
// PUT chunk: /uploads/{user}/{uuid}/{chunkIdx}
const chunkPath = `/uploads/${client.username}/${session.uploadId}/${chunkIdx}`;
// PUT chunk: /remote.php/dav/uploads/{user}/{uuid}/{chunkIdx}
const chunkPath = `/remote.php/dav/uploads/${client.username}/${session.uploadId}/${chunkIdx}`;
await client.put(chunkPath, buffer, {
Destination: session.destination,
"OC-Total-Length": String(session.totalSize),
@@ -1043,8 +1044,8 @@ async function handleChunkedUploadFinish(
const session = activeUploads.get(args.uploadId);
if (!session) return makeErrorResponse(`Upload session not found: ${args.uploadId}. It may have expired.`);
// MOVE /uploads/{user}/{uuid}/.file → destination with OC-Total-Length
const sourcePath = `/uploads/${client.username}/${session.uploadId}/.file`;
// MOVE /remote.php/dav/uploads/{user}/{uuid}/.file → destination with OC-Total-Length
const sourcePath = `/remote.php/dav/uploads/${client.username}/${session.uploadId}/.file`;
await client.move(sourcePath, session.destination, true);
@@ -1121,6 +1122,9 @@ async function handleCreateFolder(
if (status === 405) {
return makeErrorResponse(`Folder already exists: ${path}`);
}
if (status === 409) {
return makeErrorResponse(`Parent folder does not exist: ${path}. Create parent folders first.`);
}
throw err;
}
@@ -1313,7 +1317,7 @@ async function handleTrashList(
_args: Record<string, never>,
client: NextcloudClient
): Promise<ToolResponse> {
const trashPath = `/trashbin/${client.username}/trash`;
const trashPath = `/remote.php/dav/trashbin/${client.username}/trash`;
const xml = await client.propfind(trashPath, buildTrashbinPropfindBody(), "1");
const items = parseTrashbinResponse(xml);
return makeToolResponse(items);
@@ -1325,11 +1329,22 @@ async function handleTrashRestore(
): Promise<ToolResponse> {
if (!args.trashPath) return makeErrorResponse("trashPath is required");
const itemPath = `/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`;
const restoreUrl = `${client.baseUrl}/trashbin/${client.username}/restore`;
const itemPath = `/remote.php/dav/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`;
// First, get the trashbin item metadata to find the original location
const xml = await client.propfind(itemPath, buildTrashbinPropfindBody(), "0");
const items = parseTrashbinResponse(xml);
const item = items[0];
if (!item || !item.originalLocation) {
return makeErrorResponse(`Cannot determine original location for: ${args.trashPath}`);
}
// Restore by moving the trash item to its original file path
const restoreDest = `${client.baseUrl}${buildDavPath(client.username, "/" + item.originalLocation)}`;
try {
await client.move(itemPath, restoreUrl);
await client.move(itemPath, restoreDest, true);
} catch (err: any) {
const status = err?.response?.status;
if (status === 404) {
@@ -1338,7 +1353,7 @@ async function handleTrashRestore(
throw err;
}
return makeToolResponse({ success: true, restoredPath: args.trashPath });
return makeToolResponse({ success: true, restoredPath: "/" + item.originalLocation });
}
async function handleTrashDelete(
@@ -1347,7 +1362,7 @@ async function handleTrashDelete(
): Promise<ToolResponse> {
if (!args.trashPath) return makeErrorResponse("trashPath is required");
const itemPath = `/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`;
const itemPath = `/remote.php/dav/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`;
try {
await client.delete(itemPath);
@@ -1366,7 +1381,7 @@ async function handleTrashEmpty(
_args: Record<string, never>,
client: NextcloudClient
): Promise<ToolResponse> {
const trashPath = `/trashbin/${client.username}/trash`;
const trashPath = `/remote.php/dav/trashbin/${client.username}/trash`;
try {
await client.delete(trashPath);
@@ -1408,7 +1423,7 @@ async function handleGetFileVersions(
): Promise<ToolResponse> {
if (!args.fileId && args.fileId !== 0) return makeErrorResponse("fileId is required");
const versionsPath = `/versions/${client.username}/versions/${args.fileId}`;
const versionsPath = `/remote.php/dav/versions/${client.username}/versions/${args.fileId}`;
try {
const xml = await client.propfind(versionsPath, buildVersionsPropfindBody(), "1");
@@ -1430,8 +1445,8 @@ async function handleRestoreFileVersion(
if (!args.fileId && args.fileId !== 0) return makeErrorResponse("fileId is required");
if (!args.versionName) return makeErrorResponse("versionName is required");
const versionPath = `/versions/${client.username}/versions/${args.fileId}/${args.versionName}`;
const restoreUrl = `${client.baseUrl}/versions/${client.username}/restore`;
const versionPath = `/remote.php/dav/versions/${client.username}/versions/${args.fileId}/${args.versionName}`;
const restoreUrl = `${client.baseUrl}/remote.php/dav/versions/${client.username}/restore`;
try {
await client.move(versionPath, restoreUrl);
+4 -7
View File
@@ -6,9 +6,9 @@ import {
buildTasksReportBody,
formatICalDate,
formatICalDateTimeUtc,
getCalDAVXmlHeaders,
parseTasksFromCalDAV,
} from "../caldav.js";
import { escapeICalText, generateUUID as generateUID } from "../utils.js";
export const tasksToolModule: ToolModule = {
definitions: [
@@ -132,12 +132,12 @@ VERSION:2.0
PRODID:-//Nextcloud MCP Server//EN
BEGIN:VTODO
UID:${uid}
SUMMARY:${summary}
SUMMARY:${escapeICalText(summary)}
STATUS:NEEDS-ACTION
CREATED:${formatICalDateTimeUtc(new Date())}`;
if (description) {
vtodo += `\nDESCRIPTION:${description}`;
vtodo += `\nDESCRIPTION:${escapeICalText(description)}`;
}
if (due) {
vtodo += `\nDUE:${formatICalDate(new Date(due))}`;
@@ -175,7 +175,7 @@ async function updateTask(args: any, client: NextcloudClient): Promise<ToolRespo
let vtodo = String(response.data ?? "");
if (summary) {
vtodo = vtodo.replace(/SUMMARY:.*/, `SUMMARY:${summary}`);
vtodo = vtodo.replace(/SUMMARY:.*/, `SUMMARY:${escapeICalText(summary)}`);
}
if (status) {
vtodo = vtodo.replace(/STATUS:.*/, `STATUS:${status}`);
@@ -213,6 +213,3 @@ async function updateTask(args: any, client: NextcloudClient): Promise<ToolRespo
}
}
function generateUID(): string {
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
}
+23
View File
@@ -84,6 +84,29 @@ export function generateUUID(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${Math.random().toString(36).substring(2, 9)}`;
}
/** Decode XML entities in text (&amp; &lt; &gt; &quot; &apos; and numeric references). &amp; is decoded last to avoid double-decoding. */
export function decodeXmlText(value: string): string {
return value
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/&#([0-9]+);/g, (_m, dec) => String.fromCodePoint(parseInt(dec, 10)))
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}
/** Escape text for iCalendar values (RFC 5545). */
export function escapeICalText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/\n/g, "\\n")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,");
}
export function makeToolResponse(data: unknown, isError?: boolean): {
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
+12 -13
View File
@@ -2,7 +2,7 @@
// Full implementations will be added in subsequent steps
import { SearchOptions, FileMetadata, TrashedFile, FileVersion, QuotaInfo } from "./types.js";
import { normalizePath } from "./utils.js";
import { normalizePath, decodeXmlText } from "./utils.js";
// --- XML Builders ---
@@ -344,7 +344,17 @@ export function parsePropfindSingleFileResponse(xml: string, basePath: string):
}
export function parseSearchResponse(xml: string, basePath: string): FileMetadata[] {
return parsePropfindFilesResponse(xml, basePath);
// SEARCH responses don't include a root folder element (unlike PROPFIND),
// so we must NOT skip the first <response>.
const files: FileMetadata[] = [];
const responseMatches = xml.matchAll(/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g);
for (const match of responseMatches) {
const meta = parseFileMetadataFromBlock(match[0], basePath);
if (meta) files.push(meta);
}
return files;
}
/** PROPFIND trashbin → array TrashedFile.
@@ -422,17 +432,6 @@ function escapeXml(str: string): string {
.replace(/'/g, "&apos;");
}
function decodeXmlText(value: string): string {
return value
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/&#([0-9]+);/g, (_m, dec) => String.fromCodePoint(parseInt(dec, 10)))
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}
function resolveRelativePathFromHref(href: string, basePath: string): string {
const decoded = decodeURIComponent(href);