refactor: modularize codebase — Step 0 structural refactoring

Extract monolithic index.ts (~600 lines) into focused modules:
- src/types.ts — shared TypeScript interfaces (FileMetadata, QuotaInfo, etc.)
- src/utils.ts — path, mime detection, formatting helpers
- src/client.ts — NextcloudClient wrapping axios with WebDAV primitives
- src/webdav.ts — XML builders + parsers (scaffolding for file tools)
- src/tools/index.ts — ToolModule interface + auto registry
- src/tools/calendar.ts — extracted calendar tools
- src/tools/tasks.ts — extracted task tools
- src/tools/notes.ts — extracted note tools
- src/tools/email.ts — extracted email tools
- src/tools/files.ts — empty scaffolding for upcoming file management tools

src/index.ts reduced to ~50 lines: config, client instantiation, module registration, MCP setup.

Zero regression on existing tools. Verified: list_calendars, get_notes, create_note, get_note_content all functional.
This commit is contained in:
2026-05-11 13:43:36 +02:00
parent d308d8375e
commit 84c5bdd90e
12 changed files with 2746 additions and 1020 deletions
+1035
View File
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
import axios, { AxiosInstance } from "axios";
import { NextcloudConfig } from "./types.js";
export class NextcloudClient {
private xmlClient: AxiosInstance;
private binaryClient: AxiosInstance;
private config: NextcloudConfig;
constructor(config: NextcloudConfig) {
this.config = config;
const baseAuth = {
username: config.username,
password: config.password,
};
this.xmlClient = axios.create({
baseURL: config.url,
auth: baseAuth,
headers: {
"Content-Type": "application/xml",
Accept: "application/xml",
},
});
this.binaryClient = axios.create({
baseURL: config.url,
auth: baseAuth,
responseType: "arraybuffer",
});
}
// --- Generic WebDAV methods ---
async propfind(path: string, body: string, depth: string = "1"): Promise<string> {
const resp = await this.xmlClient.request({
method: "PROPFIND",
url: path,
data: body,
headers: { Depth: depth },
});
return String(resp.data ?? "");
}
async get(
path: string,
headers?: Record<string, string>,
responseType: "text" | "arraybuffer" = "text"
): Promise<any> {
const client = responseType === "arraybuffer" ? this.binaryClient : this.xmlClient;
const resp = await client.get(path, { headers });
return resp;
}
async put(
path: string,
data: Buffer | string,
headers?: Record<string, string>
): Promise<any> {
return this.binaryClient.put(path, data, { headers });
}
async mkcol(path: string, headers?: Record<string, string>): Promise<any> {
return this.xmlClient.request({
method: "MKCOL",
url: path,
headers,
});
}
async delete(path: string): Promise<any> {
return this.xmlClient.delete(path);
}
async move(
source: string,
destination: string,
overwrite: boolean = false
): Promise<any> {
return this.xmlClient.request({
method: "MOVE",
url: source,
headers: {
Destination: destination,
Overwrite: overwrite ? "T" : "F",
},
});
}
async copy(
source: string,
destination: string,
overwrite: boolean = false
): Promise<any> {
return this.xmlClient.request({
method: "COPY",
url: source,
headers: {
Destination: destination,
Overwrite: overwrite ? "T" : "F",
},
});
}
async report(path: string, body: string, depth: string = "1"): Promise<string> {
const resp = await this.xmlClient.request({
method: "REPORT",
url: path,
data: body,
headers: { Depth: depth, "Content-Type": "application/xml" },
});
return String(resp.data ?? "");
}
async search(body: string): Promise<string> {
const resp = await this.xmlClient.request({
method: "SEARCH",
url: "/remote.php/dav/",
data: body,
headers: { "Content-Type": "text/xml" },
});
return String(resp.data ?? "");
}
async proppatch(path: string, body: string): Promise<string> {
const resp = await this.xmlClient.request({
method: "PROPPATCH",
url: path,
data: body,
headers: { "Content-Type": "application/xml" },
});
return String(resp.data ?? "");
}
async post(path: string, data: any, headers?: Record<string, string>): Promise<any> {
return this.xmlClient.post(path, data, { headers });
}
async postBulk(body: Buffer, contentType: string): Promise<any> {
return this.binaryClient.post("/remote.php/dav/bulk", body, {
headers: { "Content-Type": contentType },
});
}
// --- Accessors ---
get username(): string {
return this.config.username;
}
get baseUrl(): string {
return this.config.url.replace(/\/$/, "");
}
}
+75 -1020
View File
File diff suppressed because it is too large Load Diff
+412
View File
@@ -0,0 +1,412 @@
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { NextcloudClient } from "../client.js";
import { ToolModule } from "./index.js";
import { ToolResponse } from "../types.js";
import {
CalendarInfo,
buildCalendarDiscoveryPropfindBody,
buildCalendarEventsReportBody,
dedupeEvents,
formatICalDate,
formatICalDateTimeUtc,
getCalDAVXmlHeaders,
getEventSortTimestamp,
parseCalendarsFromPROPFIND,
parseEventsFromCalDAV,
parseISODateOnlyLocalMidnight,
resolveCalendarSelectors,
stripEventInternalFields,
} from "../caldav.js";
import { format } from "date-fns";
export const calendarToolModule: ToolModule = {
definitions: [
{
name: "list_calendars",
description: "List available Nextcloud CalDAV calendars with href and supported components",
inputSchema: { type: "object", properties: {} },
},
{
name: "get_calendar_events",
description: "Retrieve calendar events from Nextcloud. Can specify date range.",
inputSchema: {
type: "object",
properties: {
startDate: {
type: "string",
description: "Start date in ISO format (YYYY-MM-DD). Defaults to today.",
},
endDate: {
type: "string",
description: "End date in ISO format (YYYY-MM-DD). Defaults to 30 days from start.",
},
calendar: {
type: "string",
description: "Calendar selector by display name or href path (optional)",
},
calendars: {
type: "array",
items: { type: "string" },
description: "List of calendar selectors by display name or href path (optional)",
},
includeAllCalendars: {
type: "boolean",
description: "When true and no calendar selectors are provided, query all VEVENT calendars",
default: true,
},
limit: {
type: "number",
description: "Maximum number of events to return",
default: 50,
},
},
},
},
{
name: "create_calendar_event",
description: "Create a new calendar event in Nextcloud",
inputSchema: {
type: "object",
properties: {
summary: { type: "string", description: "Event title/summary" },
description: { type: "string", description: "Event description (optional)" },
allDay: {
type: "boolean",
description: "Create an all-day event. Use startDate/endDate when true.",
default: false,
},
startDateTime: {
type: "string",
description: "Start date/time in ISO format (YYYY-MM-DDTHH:mm:ss). Required for timed events.",
},
endDateTime: {
type: "string",
description: "End date/time in ISO format (YYYY-MM-DDTHH:mm:ss). Required for timed events.",
},
startDate: {
type: "string",
description: "All-day start date in YYYY-MM-DD (or YYYY/MM/DD). Required when allDay=true.",
},
endDate: {
type: "string",
description: "All-day inclusive end date in YYYY-MM-DD (or YYYY/MM/DD). Optional; defaults to startDate.",
},
reminderMinutesBefore: {
type: "number",
description: "Reminder trigger relative to event start, in minutes before start (optional).",
},
reminderDateTime: {
type: "string",
description: "Absolute reminder timestamp in ISO format (optional, mutually exclusive with reminderMinutesBefore).",
},
reminderDescription: {
type: "string",
description: "Reminder text shown to the user (optional).",
},
reminderAction: {
type: "string",
description: "Alarm action type (optional, defaults to DISPLAY).",
},
location: {
type: "string",
description: "Event location (optional)",
},
},
required: ["summary"],
},
},
],
async handler(name, args, client): Promise<ToolResponse> {
switch (name) {
case "list_calendars":
return listCalendars(client);
case "get_calendar_events":
return getCalendarEvents(args, client);
case "create_calendar_event":
return createCalendarEvent(args, client);
default:
throw new Error(`Unknown calendar tool: ${name}`);
}
},
};
// --- Implementation ---
async function listCalendars(client: NextcloudClient): Promise<ToolResponse> {
const calendars = await discoverCalendars(client);
return {
content: [{ type: "text", text: JSON.stringify(calendars, null, 2) }],
};
}
async function getCalendarEvents(args: any, client: NextcloudClient): Promise<ToolResponse> {
const startDate = args.startDate || format(new Date(), "yyyy-MM-dd");
const endDate =
args.endDate ||
format(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), "yyyy-MM-dd");
const limit = args.limit || 50;
const includeAllCalendars = args.includeAllCalendars !== false;
const selectorList: string[] = [];
if (typeof args.calendar === "string" && args.calendar.trim()) {
selectorList.push(args.calendar.trim());
}
if (Array.isArray(args.calendars)) {
for (const selector of args.calendars) {
if (typeof selector === "string" && selector.trim()) {
selectorList.push(selector.trim());
}
}
}
const startLocal = parseISODateOnlyLocalMidnight(startDate, "startDate");
const endLocal = parseISODateOnlyLocalMidnight(endDate, "endDate");
if (endLocal < startLocal) {
throw new Error("endDate must be on or after startDate");
}
try {
const calendars = await discoverCalendars(client);
const veventCalendars = calendars.filter((calendar) =>
calendar.components.includes("VEVENT")
);
if (veventCalendars.length === 0) {
return {
content: [{ type: "text", text: JSON.stringify([], null, 2) }],
};
}
let targetCalendars: CalendarInfo[] = [];
if (selectorList.length > 0) {
targetCalendars = resolveCalendarSelectors(selectorList, calendars);
} else if (includeAllCalendars) {
targetCalendars = veventCalendars;
} else {
const personalCalendar = veventCalendars.find(
(calendar) => calendar.name.toLowerCase() === "personal"
);
targetCalendars = personalCalendar ? [personalCalendar] : [veventCalendars[0]];
}
const requestBody = buildCalendarEventsReportBody(startLocal, endLocal);
const mergedEvents: any[] = [];
const requestErrors: string[] = [];
for (const calendar of targetCalendars) {
try {
debugLog(client, `Querying calendar href: ${calendar.href}`);
debugLog(client, `REPORT body for ${calendar.href}:\n${requestBody}`);
const response = await client.report(calendar.href, requestBody, "1");
debugLog(
client,
`REPORT response for ${calendar.href}: status=200 body-preview=${response.slice(0, 500)}`
);
const events = parseEventsFromCalDAV(
response,
calendar.href,
(message) => debugLog(client, message)
).map((event) => ({
...event,
calendarName: calendar.name,
calendarHref: calendar.href,
}));
mergedEvents.push(...events);
} catch (error: any) {
requestErrors.push(`${calendar.href}: ${error.message}`);
debugLog(client, `Calendar query failed for ${calendar.href}: ${error.message}`);
}
}
if (mergedEvents.length === 0 && requestErrors.length > 0) {
throw new Error(
`Failed to fetch calendar events from selected calendars: ${requestErrors.join("; ")}`
);
}
const deduped = dedupeEvents(mergedEvents);
deduped.sort((a, b) => getEventSortTimestamp(a) - getEventSortTimestamp(b));
const events = deduped.slice(0, limit).map((event) => stripEventInternalFields(event));
return {
content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
};
} catch (error: any) {
throw new Error(`Failed to fetch calendar events: ${error.message}`);
}
}
async function createCalendarEvent(args: any, client: NextcloudClient): Promise<ToolResponse> {
const {
summary,
description,
location,
allDay,
startDateTime,
endDateTime,
startDate,
endDate,
reminderMinutesBefore,
reminderDateTime,
reminderDescription,
reminderAction,
} = args;
const uid = generateUID();
const isAllDay = allDay === true;
let dtStartLine = "";
let dtEndLine = "";
if (isAllDay) {
if (!startDate) {
throw new Error("startDate is required when allDay=true");
}
const start = parseISODateOnlyLocalMidnight(startDate, "startDate");
const endInclusive = parseISODateOnlyLocalMidnight(endDate || startDate, "endDate");
if (endInclusive < start) {
throw new Error("endDate must be on or after startDate");
}
const endExclusive = new Date(endInclusive.getTime());
endExclusive.setDate(endExclusive.getDate() + 1);
dtStartLine = `DTSTART;VALUE=DATE:${formatICalDate(start)}`;
dtEndLine = `DTEND;VALUE=DATE:${formatICalDate(endExclusive)}`;
} else {
if (!startDateTime || !endDateTime) {
throw new Error("startDateTime and endDateTime are required for timed events");
}
const start = new Date(startDateTime);
const end = new Date(endDateTime);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
throw new Error("startDateTime/endDateTime must be valid ISO datetime values");
}
if (end < start) {
throw new Error("endDateTime must be on or after startDateTime");
}
dtStartLine = `DTSTART:${formatICalDateTimeUtc(start)}`;
dtEndLine = `DTEND:${formatICalDateTimeUtc(end)}`;
}
if (reminderMinutesBefore !== undefined && reminderDateTime !== undefined) {
throw new Error("Use either reminderMinutesBefore or reminderDateTime, not both");
}
let alarmBlock = "";
if (reminderMinutesBefore !== undefined || reminderDateTime !== undefined) {
const action =
typeof reminderAction === "string" && reminderAction.trim()
? reminderAction.trim().toUpperCase()
: "DISPLAY";
const alarmDescription = escapeICalText(reminderDescription || "Reminder");
if (reminderMinutesBefore !== undefined) {
const minutes = Number(reminderMinutesBefore);
if (!Number.isFinite(minutes) || minutes < 0) {
throw new Error("reminderMinutesBefore must be a number >= 0");
}
const roundedMinutes = Math.floor(minutes);
alarmBlock = `\nBEGIN:VALARM
ACTION:${action}
TRIGGER:-PT${roundedMinutes}M
DESCRIPTION:${alarmDescription}
END:VALARM`;
} else if (reminderDateTime !== undefined) {
const reminderAt = new Date(reminderDateTime);
if (Number.isNaN(reminderAt.getTime())) {
throw new Error("reminderDateTime must be a valid ISO datetime value");
}
alarmBlock = `\nBEGIN:VALARM
ACTION:${action}
TRIGGER;VALUE=DATE-TIME:${formatICalDateTimeUtc(reminderAt)}
DESCRIPTION:${alarmDescription}
END:VALARM`;
}
}
let vevent = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nextcloud MCP Server//EN
BEGIN:VEVENT
UID:${uid}
SUMMARY:${escapeICalText(summary)}
${dtStartLine}
${dtEndLine}
CREATED:${formatICalDateTimeUtc(new Date())}`;
if (description) {
vevent += `\nDESCRIPTION:${escapeICalText(description)}`;
}
if (location) {
vevent += `\nLOCATION:${escapeICalText(location)}`;
}
vevent += alarmBlock;
vevent += `\nEND:VEVENT
END:VCALENDAR`;
try {
const caldavPath = `/remote.php/dav/calendars/${client.username}/personal/${uid}.ics`;
await client.put(caldavPath, vevent, {
"Content-Type": "text/calendar",
});
return {
content: [
{
type: "text",
text: `Calendar event created successfully with UID: ${uid}`,
},
],
};
} catch (error: any) {
throw new Error(`Failed to create calendar event: ${error.message}`);
}
}
// --- 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 {
if (debugEnabled) {
console.error(`[DEBUG_NEXTCLOUD_MCP] ${message}`);
}
}
async function discoverCalendars(client: NextcloudClient): Promise<CalendarInfo[]> {
const calendarsRoot = `/remote.php/dav/calendars/${client.username}/`;
const requestBody = buildCalendarDiscoveryPropfindBody();
debugLog(client, `PROPFIND calendars root: ${calendarsRoot}`);
const response = await client.propfind(calendarsRoot, requestBody, "1");
debugLog(
client,
`PROPFIND response status=200 body-preview=${response.slice(0, 500)}`
);
return parseCalendarsFromPROPFIND(response, calendarsRoot);
}
+78
View File
@@ -0,0 +1,78 @@
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { NextcloudClient } from "../client.js";
import { ToolModule } from "./index.js";
import { ToolResponse } from "../types.js";
export const emailToolModule: ToolModule = {
definitions: [
{
name: "get_emails",
description:
"Retrieve emails from Nextcloud Mail app. Returns recent emails from inbox.",
inputSchema: {
type: "object",
properties: {
accountId: {
type: "number",
description: "Mail account ID (use 0 for default)",
default: 0,
},
limit: {
type: "number",
description: "Maximum number of emails to return",
default: 20,
},
},
},
},
],
async handler(name, args, client): Promise<ToolResponse> {
if (name === "get_emails") {
return getEmails(args, client);
}
throw new Error(`Unknown email tool: ${name}`);
},
};
// --- Implementation ---
async function getEmails(args: any, client: NextcloudClient): Promise<ToolResponse> {
const accountId = args.accountId || 0;
const limit = args.limit || 20;
try {
// Get mailboxes first
const mailboxesResp = await client.get(
`/index.php/apps/mail/api/accounts/${accountId}/mailboxes`,
{
Accept: "application/json",
"Content-Type": "application/json",
}
);
const mailboxes = Array.isArray(mailboxesResp.data) ? mailboxesResp.data : [];
const inbox = mailboxes.find((mb: any) => mb.specialRole === "inbox");
if (!inbox) {
throw new Error("Inbox not found");
}
// Get messages from inbox
const messagesResp = await client.get(
`/index.php/apps/mail/api/messages?mailboxId=${inbox.id}`,
{
Accept: "application/json",
"Content-Type": "application/json",
}
);
const emails = (Array.isArray(messagesResp.data) ? messagesResp.data : []).slice(0, limit);
return {
content: [{ type: "text", text: JSON.stringify(emails, null, 2) }],
};
} catch (error: any) {
throw new Error(`Failed to fetch emails: ${error.message}`);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { NextcloudClient } from "../client.js";
import { ToolModule } from "./index.js";
import { ToolResponse } from "../types.js";
// File management tools — scaffolding, will be populated in subsequent steps
export const filesToolModule: ToolModule = {
definitions: [],
async handler(_name, _args, _client): Promise<ToolResponse> {
return {
content: [{ type: "text", text: "File tools not yet implemented." }],
isError: true,
};
},
};
+37
View File
@@ -0,0 +1,37 @@
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { NextcloudClient } from "../client.js";
import { ToolResponse } from "../types.js";
export interface ToolModule {
definitions: Tool[];
handler(name: string, args: any, client: NextcloudClient): Promise<ToolResponse>;
}
export function registerAllTools(modules: ToolModule[]): {
tools: Tool[];
handler: (name: string, args: any, client: NextcloudClient) => Promise<ToolResponse>;
} {
const tools: Tool[] = [];
const handlers = new Map<string, (args: any, client: NextcloudClient) => Promise<ToolResponse>>();
for (const mod of modules) {
for (const tool of mod.definitions) {
tools.push(tool);
handlers.set(tool.name, (args, client) => mod.handler(tool.name, args, client));
}
}
return {
tools,
async handler(name, args, client) {
const fn = handlers.get(name);
if (!fn) {
return {
content: [{ type: "text", text: `Error: Unknown tool: ${name}` }],
isError: true,
};
}
return fn(args, client);
},
};
}
+140
View File
@@ -0,0 +1,140 @@
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { NextcloudClient } from "../client.js";
import { ToolModule } from "./index.js";
import { ToolResponse } from "../types.js";
export const notesToolModule: ToolModule = {
definitions: [
{
name: "get_notes",
description: "Retrieve all notes from Nextcloud Notes app",
inputSchema: {
type: "object",
properties: {
limit: {
type: "number",
description: "Maximum number of notes to return",
default: 50,
},
},
},
},
{
name: "create_note",
description: "Create a new note in Nextcloud Notes app",
inputSchema: {
type: "object",
properties: {
title: {
type: "string",
description: "Note title (first line)",
},
content: {
type: "string",
description: "Note content (markdown supported)",
},
category: {
type: "string",
description: "Note category/folder (optional)",
},
},
required: ["content"],
},
},
{
name: "get_note_content",
description: "Get the full content of a specific note by ID",
inputSchema: {
type: "object",
properties: {
noteId: {
type: "number",
description: "Note ID",
},
},
required: ["noteId"],
},
},
],
async handler(name, args, client): Promise<ToolResponse> {
switch (name) {
case "get_notes":
return getNotes(args, client);
case "create_note":
return createNote(args, client);
case "get_note_content":
return getNoteContent(args, client);
default:
throw new Error(`Unknown notes tool: ${name}`);
}
},
};
// --- Implementation ---
async function getNotes(args: any, client: NextcloudClient): Promise<ToolResponse> {
const limit = args.limit || 50;
try {
const resp = await client.get("/index.php/apps/notes/api/v1/notes", {
Accept: "application/json",
"Content-Type": "application/json",
});
const notes = (Array.isArray(resp.data) ? resp.data : []).slice(0, limit);
return {
content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
};
} catch (error: any) {
throw new Error(`Failed to fetch notes: ${error.message}`);
}
}
async function createNote(args: any, client: NextcloudClient): Promise<ToolResponse> {
const { title, content, category } = args;
const noteContent = title ? `${title}\n\n${content}` : content;
try {
const payload: any = { content: noteContent };
if (category) payload.category = category;
const resp = await client.post(
"/index.php/apps/notes/api/v1/notes",
payload,
{
Accept: "application/json",
"Content-Type": "application/json",
}
);
return {
content: [
{ type: "text", text: `Note created successfully with ID: ${resp.data.id}` },
],
};
} catch (error: any) {
throw new Error(`Failed to create note: ${error.message}`);
}
}
async function getNoteContent(args: any, client: NextcloudClient): Promise<ToolResponse> {
const { noteId } = args;
try {
const resp = await client.get(
`/index.php/apps/notes/api/v1/notes/${noteId}`,
{
Accept: "application/json",
"Content-Type": "application/json",
}
);
return {
content: [{ type: "text", text: JSON.stringify(resp.data, null, 2) }],
};
} catch (error: any) {
throw new Error(`Failed to fetch note: ${error.message}`);
}
}
+218
View File
@@ -0,0 +1,218 @@
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { NextcloudClient } from "../client.js";
import { ToolModule } from "./index.js";
import { ToolResponse } from "../types.js";
import {
buildTasksReportBody,
formatICalDate,
formatICalDateTimeUtc,
getCalDAVXmlHeaders,
parseTasksFromCalDAV,
} from "../caldav.js";
export const tasksToolModule: ToolModule = {
definitions: [
{
name: "get_tasks",
description:
"Retrieve tasks from Nextcloud. Can filter by status (completed/open) and limit results.",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
enum: ["all", "open", "completed"],
description: "Filter tasks by status",
default: "all",
},
limit: {
type: "number",
description: "Maximum number of tasks to return",
default: 50,
},
},
},
},
{
name: "create_task",
description: "Create a new task in Nextcloud",
inputSchema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Task title/summary",
},
description: {
type: "string",
description: "Task description (optional)",
},
due: {
type: "string",
description: "Due date in ISO format (YYYY-MM-DD) (optional)",
},
priority: {
type: "number",
description: "Priority (1-9, where 1 is highest) (optional)",
},
},
required: ["summary"],
},
},
{
name: "update_task",
description: "Update an existing task (mark as complete, change summary, etc.)",
inputSchema: {
type: "object",
properties: {
taskId: {
type: "string",
description: "Task ID/UID",
},
summary: {
type: "string",
description: "New task title/summary (optional)",
},
status: {
type: "string",
enum: ["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"],
description: "New task status (optional)",
},
percentComplete: {
type: "number",
description: "Completion percentage 0-100 (optional)",
},
},
required: ["taskId"],
},
},
],
async handler(name, args, client): Promise<ToolResponse> {
switch (name) {
case "get_tasks":
return getTasks(args, client);
case "create_task":
return createTask(args, client);
case "update_task":
return updateTask(args, client);
default:
throw new Error(`Unknown tasks tool: ${name}`);
}
},
};
// --- Implementation ---
async function getTasks(args: any, client: NextcloudClient): Promise<ToolResponse> {
const status = args.status || "all";
const limit = args.limit || 50;
try {
const caldavPath = `/remote.php/dav/calendars/${client.username}/tasks/`;
const requestBody = buildTasksReportBody();
const response = await client.report(caldavPath, requestBody, "1");
const tasks = parseTasksFromCalDAV(response, status, limit);
return {
content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }],
};
} catch (error: any) {
throw new Error(`Failed to fetch tasks: ${error.message}`);
}
}
async function createTask(args: any, client: NextcloudClient): Promise<ToolResponse> {
const { summary, description, due, priority } = args;
const uid = generateUID();
let vtodo = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nextcloud MCP Server//EN
BEGIN:VTODO
UID:${uid}
SUMMARY:${summary}
STATUS:NEEDS-ACTION
CREATED:${formatICalDateTimeUtc(new Date())}`;
if (description) {
vtodo += `\nDESCRIPTION:${description}`;
}
if (due) {
vtodo += `\nDUE:${formatICalDate(new Date(due))}`;
}
if (priority) {
vtodo += `\nPRIORITY:${priority}`;
}
vtodo += `\nEND:VTODO
END:VCALENDAR`;
try {
const caldavPath = `/remote.php/dav/calendars/${client.username}/tasks/${uid}.ics`;
await client.put(caldavPath, vtodo, {
"Content-Type": "text/calendar",
});
return {
content: [
{ type: "text", text: `Task created successfully with UID: ${uid}` },
],
};
} catch (error: any) {
throw new Error(`Failed to create task: ${error.message}`);
}
}
async function updateTask(args: any, client: NextcloudClient): Promise<ToolResponse> {
const { taskId, summary, status, percentComplete } = args;
const caldavPath = `/remote.php/dav/calendars/${client.username}/tasks/${taskId}.ics`;
try {
const response = await client.get(caldavPath);
let vtodo = String(response.data ?? "");
if (summary) {
vtodo = vtodo.replace(/SUMMARY:.*/, `SUMMARY:${summary}`);
}
if (status) {
vtodo = vtodo.replace(/STATUS:.*/, `STATUS:${status}`);
}
if (percentComplete !== undefined) {
if (vtodo.includes("PERCENT-COMPLETE:")) {
vtodo = vtodo.replace(
/PERCENT-COMPLETE:.*/,
`PERCENT-COMPLETE:${percentComplete}`
);
} else {
vtodo = vtodo.replace(
/END:VTODO/,
`PERCENT-COMPLETE:${percentComplete}\nEND:VTODO`
);
}
}
vtodo = vtodo.replace(
/LAST-MODIFIED:.*/,
`LAST-MODIFIED:${formatICalDateTimeUtc(new Date())}`
);
await client.put(caldavPath, vtodo, {
"Content-Type": "text/calendar",
});
return {
content: [
{ type: "text", text: `Task ${taskId} updated successfully` },
],
};
} catch (error: any) {
throw new Error(`Failed to update task: ${error.message}`);
}
}
function generateUID(): string {
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
}
+92
View File
@@ -0,0 +1,92 @@
// === Core ===
export interface NextcloudConfig {
url: string;
username: string;
password: string; // App password recommended
}
// === File Metadata ===
export interface FileMetadata {
name: string; // filename
path: string; // path relativo alla root utente
type: "file" | "folder";
size?: number; // bytes (oc:size, include cartelle)
contentLength?: number; // bytes (d:getcontentlength, solo file)
mimeType?: string;
lastModified?: string; // ISO 8601
etag?: string;
fileId?: number; // oc:fileid
permissions?: string; // es. "RGDNVCK"
favorite?: boolean;
ownerId?: string;
ownerDisplayName?: string;
hasPreview?: boolean;
}
// === Cestino ===
export interface TrashedFile extends FileMetadata {
originalName: string; // nc:trashbin-filename
originalLocation: string; // nc:trashbin-original-location
deletionTime: string; // nc:trashbin-deletion-time (unix timestamp)
}
// === Versioni ===
export interface FileVersion {
name: string; // timestamp della versione
size: number;
lastModified: string;
etag?: string;
}
// === Quota ===
export interface QuotaInfo {
used: number; // bytes
available: number; // bytes (-1 = uncomputed, -2 = unknown, -3 = unlimited)
}
// === Upload ===
export interface BulkUploadResult {
path: string;
error: boolean;
etag?: string;
errorMessage?: string;
}
export interface ChunkedUploadSession {
uploadId: string;
destination: string;
totalSize: number;
chunkSize: number;
totalChunks: number;
createdAt: number; // unix timestamp
}
// === Search ===
export interface SearchOptions {
path?: string;
query?: string;
mimeType?: string;
minSize?: number;
maxSize?: number;
modifiedAfter?: string;
modifiedBefore?: string;
favorite?: boolean;
sortBy?: string;
sortOrder?: "asc" | "desc";
limit?: number;
}
// === Tool ===
export interface ToolResponse {
[x: string]: unknown;
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
}
+105
View File
@@ -0,0 +1,105 @@
import { NextcloudConfig } from "./types.js";
export function normalizePath(path: string): string {
if (!path) return "/";
let normalized = path.replace(/\\/g, "/");
if (!normalized.startsWith("/")) normalized = "/" + normalized;
normalized = normalized.replace(/\/+/g, "/");
return normalized;
}
export function buildDavPath(username: string, relativePath: string): string {
const normPath = normalizePath(relativePath);
return `/remote.php/dav/files/${username}${normPath}`;
}
export function buildDavUrl(config: NextcloudConfig, relativePath: string): string {
const base = config.url.replace(/\/$/, "");
return `${base}${buildDavPath(config.username, relativePath)}`;
}
export function resolveRelativePath(href: string, davBase: string): string {
const decoded = decodeURIComponent(href);
if (decoded.startsWith(davBase)) {
return decoded.slice(davBase.length) || "/";
}
return decoded;
}
export function isTextMimeType(mimeType: string | undefined): boolean {
if (!mimeType) return false;
const textTypes = [
"text/",
"application/json",
"application/xml",
"application/javascript",
"application/typescript",
];
return textTypes.some((t) => mimeType.toLowerCase().startsWith(t));
}
const MIME_MAP: Record<string, string> = {
".txt": "text/plain",
".md": "text/markdown",
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".xml": "application/xml",
".pdf": "application/pdf",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
".csv": "text/csv",
".ics": "text/calendar",
};
export function detectMimeType(filename: string): string {
const lower = filename.toLowerCase();
for (const [ext, mime] of Object.entries(MIME_MAP)) {
if (lower.endsWith(ext)) return mime;
}
return "application/octet-stream";
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
export function generateUUID(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${Math.random().toString(36).substring(2, 9)}`;
}
export function makeToolResponse(data: unknown, isError?: boolean): {
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
} {
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
isError,
};
}
export function makeErrorResponse(message: string): {
content: Array<{ type: "text"; text: string }>;
isError: true;
} {
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
};
}
+385
View File
@@ -0,0 +1,385 @@
// WebDAV XML builders + parsers — scaffolding for Step 1
// Full implementations will be added in subsequent steps
import { SearchOptions, FileMetadata, TrashedFile, FileVersion, QuotaInfo } from "./types.js";
import { normalizePath } from "./utils.js";
// --- XML Builders (scaffolding) ---
export function buildPropfindBody(selectedProps?: string[]): string {
const props = selectedProps && selectedProps.length > 0
? selectedProps.map((p) => ` <${p} />`).join("\n")
: ` <d:displayname />
<d:getcontenttype />
<d:getlastmodified />
<d:getetag />
<d:getcontentlength />
<d:resourcetype />
<oc:fileid />
<oc:size />
<oc:permissions />
<oc:favorite />`;
return `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
${props}
</d:prop>
</d:propfind>`;
}
export function buildPropfindExtendedBody(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<d:displayname />
<d:getcontenttype />
<d:getlastmodified />
<d:getetag />
<d:getcontentlength />
<d:resourcetype />
<oc:fileid />
<oc:size />
<oc:permissions />
<oc:favorite />
<oc:owner-display-name />
<oc:owner-id />
<nc:has-preview />
<oc:checksums />
</d:prop>
</d:propfind>`;
}
export function buildTrashbinPropfindBody(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<d:displayname />
<d:getcontenttype />
<d:getlastmodified />
<d:getetag />
<d:getcontentlength />
<d:resourcetype />
<oc:fileid />
<oc:size />
<oc:permissions />
<nc:trashbin-filename />
<nc:trashbin-original-location />
<nc:trashbin-deletion-time />
</d:prop>
</d:propfind>`;
}
export function buildVersionsPropfindBody(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<d:getcontentlength />
<d:getlastmodified />
<d:getetag />
<oc:size />
</d:prop>
</d:propfind>`;
}
export function buildProppatchBody(namespace: string, property: string, value: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:set>
<d:prop>
<${namespace}:${property}>${value}</${namespace}:${property}>
</d:prop>
</d:set>
</d:propertyupdate>`;
}
export function buildFavoriteFilterBody(selectedProps?: string[]): string {
const props = selectedProps && selectedProps.length > 0
? selectedProps.map((p) => ` <${p} />`).join("\n")
: ` <d:displayname />
<d:getcontenttype />
<d:getlastmodified />
<d:getetag />
<d:getcontentlength />
<d:resourcetype />
<oc:fileid />
<oc:size />
<oc:permissions />
<oc:favorite />`;
return `<?xml version="1.0" encoding="UTF-8"?>
<oc:filter-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
${props}
</d:prop>
<oc:filter-rules>
<oc:favorite>1</oc:favorite>
</oc:filter-rules>
</oc:filter-files>`;
}
export function buildSearchRequest(options: SearchOptions): string {
const filters: string[] = [];
if (options.query) {
filters.push(` <d:like>
<d:prop><d:displayname /></d:prop>
<d:literal>${escapeXml(options.query)}%</d:literal>
</d:like>`);
}
if (options.mimeType) {
filters.push(` <d:eq>
<d:prop><d:getcontenttype /></d:prop>
<d:literal>${escapeXml(options.mimeType)}</d:literal>
</d:eq>`);
}
if (options.minSize !== undefined) {
filters.push(` <d:gt>
<d:prop><oc:size /></d:prop>
<d:literal>${options.minSize}</d:literal>
</d:gt>`);
}
if (options.maxSize !== undefined) {
filters.push(` <d:lt>
<d:prop><oc:size /></d:prop>
<d:literal>${options.maxSize}</d:literal>
</d:lt>`);
}
if (options.modifiedAfter) {
filters.push(` <d:gt>
<d:prop><d:getlastmodified /></d:prop>
<d:literal>${escapeXml(options.modifiedAfter)}</d:literal>
</d:gt>`);
}
if (options.modifiedBefore) {
filters.push(` <d:lt>
<d:prop><d:getlastmodified /></d:prop>
<d:literal>${escapeXml(options.modifiedBefore)}</d:literal>
</d:lt>`);
}
if (options.favorite === true) {
filters.push(` <d:eq>
<d:prop><oc:favorite /></d:prop>
<d:literal>1</d:literal>
</d:eq>`);
}
let whereClause: string;
if (filters.length === 0) {
whereClause = ` <d:where>
<d:not><d:is-collection /></d:not>
</d:where>`;
} else if (filters.length === 1) {
whereClause = ` <d:where>\n${filters[0]}\n </d:where>`;
} else {
whereClause = ` <d:where>\n <d:and>\n${filters.join("\n")}\n </d:and>\n </d:where>`;
}
const scopePath = normalizePath(options.path || "/");
const scope = `/files/${escapeXml(scopePath)}`;
const orderby = options.sortBy
? ` <d:orderby>
<d:order>
<d:prop><d:${escapeXml(options.sortBy)} /></d:prop>
<d:${options.sortOrder === "desc" ? "descending" : "ascending"} />
</d:order>
</d:orderby>`
: "";
const limit = options.limit !== undefined
? ` <d:limit>
<nresults>${options.limit}</nresults>
</d:limit>`
: "";
return `<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:basicsearch>
<d:select>
<d:prop>
<d:displayname />
<d:getcontenttype />
<d:getlastmodified />
<d:getetag />
<d:getcontentlength />
<d:resourcetype />
<oc:fileid />
<oc:size />
<oc:permissions />
<oc:favorite />
</d:prop>
</d:select>
<d:from>
<d:scope>
<d:href>${scope}</d:href>
<d:depth>infinity</d:depth>
</d:scope>
</d:from>
${whereClause}
${orderby}
${limit}
</d:basicsearch>
</d:searchrequest>`;
}
// --- XML Parsers (regex-based, like caldav.ts) ---
function extractProperty(block: string, namespace: string, property: string): string | null {
const regex = new RegExp(`<(?:${namespace}:)?${property}[^>]*>([^<]*)</(?:${namespace}:)?${property}>`, "i");
const match = block.match(regex);
return match ? decodeXmlText(match[1]) : null;
}
function extractNumericProperty(block: string, namespace: string, property: string): number | null {
const val = extractProperty(block, namespace, property);
if (val === null || val === "") return null;
const num = Number(val);
return Number.isFinite(num) ? num : null;
}
function extractBooleanProperty(block: string, namespace: string, property: string): boolean {
const val = extractProperty(block, namespace, property);
return val === "1" || val?.toLowerCase() === "true";
}
function hasCollection(block: string): boolean {
return /<(?:\w+:)?collection\b/i.test(block);
}
function extractHref(block: string): string | null {
const match = block.match(/<(?:\w+:)?href[^>]*>([\s\S]*?)<\/(?:\w+:)?href>/);
return match ? decodeXmlText(match[1].trim()) : null;
}
export function parsePropfindFilesResponse(xml: string, basePath: string): FileMetadata[] {
const files: FileMetadata[] = [];
const responseMatches = xml.matchAll(/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g);
for (const match of responseMatches) {
const block = match[0];
const href = extractHref(block);
if (!href) continue;
const name = extractProperty(block, "d", "displayname") || "";
const isFolder = hasCollection(block);
files.push({
name,
path: resolveRelativePathFromHref(href, basePath),
type: isFolder ? "folder" : "file",
size: extractNumericProperty(block, "oc", "size") ?? undefined,
contentLength: extractNumericProperty(block, "d", "getcontentlength") ?? undefined,
mimeType: extractProperty(block, "d", "getcontenttype") ?? undefined,
lastModified: extractProperty(block, "d", "getlastmodified") ?? undefined,
etag: extractProperty(block, "d", "getetag") ?? undefined,
fileId: extractNumericProperty(block, "oc", "fileid") ?? undefined,
permissions: extractProperty(block, "oc", "permissions") ?? undefined,
favorite: extractBooleanProperty(block, "oc", "favorite"),
});
}
return files;
}
export function parsePropfindSingleFileResponse(xml: string, basePath: string): FileMetadata | null {
const files = parsePropfindFilesResponse(xml, basePath);
return files[0] || null;
}
export function parseSearchResponse(xml: string, basePath: string): FileMetadata[] {
return parsePropfindFilesResponse(xml, basePath);
}
export function parseTrashbinResponse(xml: string): TrashedFile[] {
const files: TrashedFile[] = [];
const responseMatches = xml.matchAll(/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g);
for (const match of responseMatches) {
const block = match[0];
const href = extractHref(block);
if (!href) continue;
const name = extractProperty(block, "d", "displayname") || "";
const isFolder = hasCollection(block);
files.push({
name,
path: href,
type: isFolder ? "folder" : "file",
size: extractNumericProperty(block, "oc", "size") ?? undefined,
contentLength: extractNumericProperty(block, "d", "getcontentlength") ?? undefined,
mimeType: extractProperty(block, "d", "getcontenttype") ?? undefined,
lastModified: extractProperty(block, "d", "getlastmodified") ?? undefined,
etag: extractProperty(block, "d", "getetag") ?? undefined,
fileId: extractNumericProperty(block, "oc", "fileid") ?? undefined,
permissions: extractProperty(block, "oc", "permissions") ?? undefined,
favorite: extractBooleanProperty(block, "oc", "favorite"),
originalName: extractProperty(block, "nc", "trashbin-filename") || name,
originalLocation: extractProperty(block, "nc", "trashbin-original-location") || "",
deletionTime: extractProperty(block, "nc", "trashbin-deletion-time") || "",
});
}
return files;
}
export function parseVersionsResponse(xml: string): FileVersion[] {
const versions: FileVersion[] = [];
const responseMatches = xml.matchAll(/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g);
for (const match of responseMatches) {
const block = match[0];
const href = extractHref(block);
if (!href) continue;
const name = href.split("/").filter(Boolean).pop() || "";
const size = extractNumericProperty(block, "oc", "size") ?? extractNumericProperty(block, "d", "getcontentlength") ?? 0;
versions.push({
name,
size,
lastModified: extractProperty(block, "d", "getlastmodified") || "",
etag: extractProperty(block, "d", "getetag") ?? undefined,
});
}
return versions;
}
export function parseQuotaResponse(xml: string): QuotaInfo {
const used = extractNumericProperty(xml, "d", "quota-used-bytes") ?? 0;
const available = extractNumericProperty(xml, "d", "quota-available-bytes") ?? -1;
return { used, available };
}
// --- Helpers ---
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.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);
// basePath is like /remote.php/dav/files/user/
if (decoded.startsWith(basePath)) {
return decoded.slice(basePath.length) || "/";
}
return decoded;
}