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:
+1
-96
@@ -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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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
@@ -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
@@ -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
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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 (& < > " ' and numeric references). & 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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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
@@ -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, "'");
|
||||
}
|
||||
|
||||
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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function resolveRelativePathFromHref(href: string, basePath: string): string {
|
||||
const decoded = decodeURIComponent(href);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user