From 84c5bdd90ecd0a2890d132b8eb80a60825f850ca Mon Sep 17 00:00:00 2001 From: bea Date: Mon, 11 May 2026 13:43:36 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20modularize=20codebase=20=E2=80=94?= =?UTF-8?q?=20Step=200=20structural=20refactoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PLAN.md | 1035 ++++++++++++++++++++++++++++++++++++++ src/client.ts | 153 ++++++ src/index.ts | 1095 +++-------------------------------------- src/tools/calendar.ts | 412 ++++++++++++++++ src/tools/email.ts | 78 +++ src/tools/files.ts | 16 + src/tools/index.ts | 37 ++ src/tools/notes.ts | 140 ++++++ src/tools/tasks.ts | 218 ++++++++ src/types.ts | 92 ++++ src/utils.ts | 105 ++++ src/webdav.ts | 385 +++++++++++++++ 12 files changed, 2746 insertions(+), 1020 deletions(-) create mode 100644 PLAN.md create mode 100644 src/client.ts create mode 100644 src/tools/calendar.ts create mode 100644 src/tools/email.ts create mode 100644 src/tools/files.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/notes.ts create mode 100644 src/tools/tasks.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts create mode 100644 src/webdav.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..8b9cde6 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1035 @@ +# PLAN.md — Nextcloud MCP: File Management Evolutive + +## 1. Analisi del Codice Attuale + +### Struttura del progetto + +``` +nextcloud-mcp/ +├── src/ +│ ├── index.ts (~600 righe) — Server MCP principale, monolitico +│ └── caldav.ts (~450 righe) — Helper CalDAV (XML builder + parser) +├── build/ — Output TypeScript compilato +├── ncmcp.mjs — Wrapper CLI (auto-load .env, sintassi `ncmcp [key=value]`) +├── package.json — deps: @modelcontextprotocol/sdk, axios, date-fns, ical.js, zod +├── tsconfig.json — target ES2022, module Node16, strict +└── test-connection.js +``` + +### Pattern architetturale esistente + +- **Classe singola** `NextcloudMCPServer` in `index.ts` con tutti i tool come metodi privati +- **Modulo separato** `caldav.ts` per la logica dominio CalDAV (builder XML, parser iCal, tipi) +- **Registrazione tool**: `getTools()` restituisce array di `Tool`, `setupHandlers()` fa switch sul nome +- **HTTP**: `axios` con basic auth, headers XML di default +- **Risposta tool**: `{ content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }` +- **Errore**: catch → `{ content: [{ type: "text", text: "Error: ..." }], isError: true }` +- **XML parsing**: regex-based (no libreria XML), funziona ma fragile + +### Criticità attuali + +1. `index.ts` sta diventando troppo grande — aggiungere 20+ tool file peggiorerà la situazione +2. Lo switch in `setupHandlers()` è ripetitivo — ogni nuovo tool aggiunge un case +3. Il parser XML basato su regex in `caldav.ts` funziona per CalDAV ma il PROPFIND di WebDAV ha namespace più complessi +4. L'axios instance ha `Content-Type: application/xml` di default — i tool file che inviano binario (PUT upload) o JSON (Note/Email) lo sovrascrivono caso per caso +5. Nessuna separazione tra definizione tool, logica API e parsing XML + +--- + +## 2. API Nextcloud — Riepilogo Endpoint WebDAV + +Tutti gli endpoint sono sotto `/remote.php/dav/` con autenticazione Basic Auth. + +### Operazioni base (Files) + +| Operazione | Metodo | Endpoint | Note | +|---|---|---|---| +| Lista file/cartelle | `PROPFIND` | `/files/{user}/{path}` | `Depth: 1` per listing, `Depth: 0` per singolo | +| Info singolo file | `PROPFIND` | `/files/{user}/{path}` | `Depth: 0` | +| Download file | `GET` | `/files/{user}/{path}` | Ritorna raw bytes | +| Download cartella (ZIP) | `GET` | `/files/{user}/{folder}` | `Accept: application/zip` | +| Download selettivo | `GET` | `/files/{user}/{folder}?accept=zip&files=["a.txt","b.png"]` | oppure header `X-NC-Files` | +| Upload file | `PUT` | `/files/{user}/{path}` | Body = raw content, sovrascrive se esiste | +| Crea cartella | `MKCOL` | `/files/{user}/{path}` | | +| Elimina | `DELETE` | `/files/{user}/{path}` | Cartelle eliminate ricorsivamente, finisce nel trashbin | +| Sposta/Rinomina | `MOVE` | `/files/{user}/{source}` | Header `Destination: {full_url}` + `Overwrite: T/F` | +| Copia | `COPY` | `/files/{user}/{source}` | Header `Destination: {full_url}` + `Overwrite: T/F` | +| Imposta preferito | `PROPPATCH` | `/files/{user}/{path}` | `oc:favorite` = 0 o 1 | +| Lista preferiti | `REPORT` | `/files/{user}/{path}` | `oc:filter-files` con `oc:favorite=1` | + +### Proprietà WebDAV supportate (namespace) + +| Proprietà | Namespace | Descrizione | +|---|---|---| +| `displayname` | `d:` | Nome file | +| `getcontenttype` | `d:` | MIME type | +| `getlastmodified` | `d:` | Data modifica | +| `getetag` | `d:` | ETag | +| `getcontentlength` | `d:` | Dimensione (solo file) | +| `resourcetype` | `d:` | `d:collection` per cartelle | +| `fileid` | `oc:` | ID univoco nel server | +| `size` | `oc:` | Dimensione (file + cartelle) | +| `permissions` | `oc:` | Stringa permessi (S,R,M,G,D,N,V,W,C,K) | +| `favorite` | `oc:` | 0 o 1 | +| `owner-display-name` | `oc:` | Proprietario | +| `share-types` | `oc:` | Array XML tipi share | +| `has-preview` | `nc:` | Preview disponibile | +| `trashbin-filename` | `nc:` | Nome originale nel cestino | +| `trashbin-original-location` | `nc:` | Path originale nel cestino | +| `trashbin-deletion-time` | `nc:` | Timestamp eliminazione | + +### Ricerca (WebDAV SEARCH, rfc5323) + +| Aspect | Dettaglio | +|---|---| +| Endpoint | `SEARCH /remote.php/dav/` | +| Content-Type | `text/xml` | +| Scope | `/files/{user}/{folder}`, `depth: infinity` | +| Filtri supportati | `eq`, `ne`, `lt`, `gt`, `le`, `ge`, `like`, `is-collection`, `and`, `or`, `not` | +| Campi cercabili | `displayname`, `getcontenttype`, `getlastmodified`, `creationdate`, `upload_time`, `last_activity`, `size`, `favorite`, `fileid` | +| Ordinamento | `orderby` con `ascending/descending` | +| Limit | `nresults` nel blocco `limit` | + +### Cestino (Trashbin) + +| Operazione | Metodo | Endpoint | +|---|---|---| +| Lista cestino | `PROPFIND` | `/trashbin/{user}/trash` | +| Ripristina | `MOVE` | `/trashbin/{user}/trash/{item}` → `/trashbin/{user}/restore` | +| Elimina dal cestino | `DELETE` | `/trashbin/{user}/trash/{item}` | +| Svuota cestino | `DELETE` | `/trashbin/{user}/trash` | + +### Versioni file + +| Operazione | Metodo | Endpoint | +|---|---|---| +| Lista versioni | `PROPFIND` | `/versions/{user}/versions/{fileId}` | +| Ripristina versione | `MOVE` | `/versions/{user}/versions/{version}` → `/versions/{user}/restore` | + +### Bulk Upload + +| Operazione | Metodo | Endpoint | +|---|---|---| +| Upload multiplo | `POST` | `/remote.php/dav/bulk` | +| Content-Type | `multipart/related` | | +| Headers per parte | `X-File-Path`, `X-OC-Mtime`, `X-File-Md5`, `Content-Length`, `Content-Type` | +| Risposta | JSON: `{"/path": {"error": false, "etag": "..."}}` | | + +### Chunked Upload v2 (file grandi) + +| Operazione | Metodo | Endpoint | +|---|---|---| +| Crea sessione | `MKCOL` | `/uploads/{user}/{uuid}` + `Destination` header | +| Upload chunk | `PUT` | `/uploads/{user}/{uuid}/{NNNNN}` + `Destination` + `OC-Total-Length` | +| Assembla | `MOVE` | `/uploads/{user}/{uuid}/.file` + `Destination` + `OC-Total-Length` | +| Annulla | `DELETE` | `/uploads/{user}/{uuid}/` | +| Limite chunk | 5MB–5GB (ultimo chunk < 5MB ok) | | +| Scadenza | 24h di inattività | | + +--- + +## 3. Design dei Tool MCP + +### Principi di design + +1. **Ogni tool = una singola operazione WebDAV** — mappatura 1:1, prevedibile +2. **Paths relativi alla root utente** — il tool riceve `/Documents/file.pdf`, il codice risolve in `/remote.php/dav/files/{user}/Documents/file.pdf` +3. **Metadata-first** — `list_files` e `get_file_info` restituiscono solo metadati JSON +4. **Content on-demand** — `read_file` restituisce contenuto (testo o base64), con soglia dimensione +5. **Base64 per binary** — MCP non supporta binary nativamente; file binari vengono ritornati come base64 con metadata +6. **Follow existing patterns** — stesso formato risposta, stessa gestione errori del codice esistente +7. **Smart routing per dimensioni** — il limite non è su Nextcloud (che gestisce file di qualsiasi dimensione), ma sul **trasporto MCP** (stdio/JSON). La strategia è: + - **Upload piccolo** (<10MB nel param): `upload_file` con PUT diretto + - **Upload grande** (≥10MB o file locali): `chunked_upload_start` → `chunked_upload_chunk` (N volte) → `chunked_upload_finish` + - **Read piccolo** (<10MB): `read_file` restituisce contenuto inline + - **Read grande** (≥10MB): `read_file` restituisce errore con suggerimento `download_file`; `download_file` restituisce URL diretto per scaricare fuori banda + +### Lista tool (21 tool totali) + +#### 🔷 Browsing & Discovery (5 tool) + +``` +list_files + path: string (default: "/") + properties?: string[] (default: standard set) + depth?: "0"|"1"|"infinity" (default: "1") + → FileMetadata[] +``` +PROPFIND sulla cartella. Restituisce array di oggetti con: name, path, type (file|folder), size, mimeType, lastModified, etag, fileId, permissions, favorite. + +``` +get_file_info + path: string (obbligatorio) + → FileMetadata (singolo oggetto dettagliato) +``` +PROPFIND Depth: 0. Include tutte le proprietà disponibili (owner, share-types, checksum, etc.). + +``` +search_files + path?: string (default: "/" — scope ricerca) + query?: string (pattern nome, tipo "like") + mimeType?: string (esatta o con wildcard "image/%") + minSize?: number (bytes) + maxSize?: number (bytes) + modifiedAfter?: string (ISO 8601) + modifiedBefore?: string (ISO 8601) + favorite?: boolean + sortBy?: "name"|"size"|"lastModified"|"created" + sortOrder?: "asc"|"desc" + limit?: number (default: 50) + → FileMetadata[] +``` +SEARCH request (rfc5323). Costruisce dinamicamente il XML `` in base ai parametri forniti. + +``` +list_favorites + path?: string (default: "/" — ricerca ricorsiva) + → FileMetadata[] +``` +REPORT con `oc:filter-files` + `oc:favorite=1`. + +``` +get_quota + → { used: number, available: number, total?: number } +``` +PROPFIND Depth: 0 sulla root utente, richiedendo `d:quota-used-bytes` e `d:quota-available-bytes`. + +#### 🔷 Read & Download (3 tool) + +``` +read_file + path: string (obbligatorio) + encoding?: "utf8"|"base64" (default: "utf8", auto-detect per binary) + maxSize?: number (default: 10_485_760 = 10MB, ritorna errore se superato) + → { metadata: FileMetadata, content: string, encoding: string, size: number } +``` +GET request. Per file testo: ritorna UTF-8. Per file binari o se `encoding="base64"`: ritorna base64. +Se il file supera `maxSize`: ritorna errore con suggerimento di usare `download_file` (URL diretto). +Il limite è un vincolo del trasporto MCP (JSON su stdio), non di Nextcloud. + +``` +download_file + path: string (obbligatorio) + → { metadata: FileMetadata, downloadUrl: string } +``` +Restituisce l'URL diretto per download. Utile per file grandi che non possono passare through MCP. +L'agent/user scarica il file usando questo URL (curl, browser, wget, ecc.). + +``` +download_folder + path: string (obbligatorio, deve essere cartella) + format?: "zip"|"tar" (default: "zip") + files?: string[] (sottoinsieme di file da includere, default: tutti) + maxSize?: number (default: 50_971_520 = 50MB) + → { metadata: { path, format, fileCount, totalSize }, content?: string, encoding: "base64" } + oppure se troppo grande: + → { metadata: { path, format, fileCount, totalSize }, downloadUrl: string } +``` +GET con `Accept: application/zip` (o tar). Se specificato `files`, usa header `X-NC-Files`. +Se la cartella supera `maxSize`: ritorna solo URL con parametri query per download esterno. + +#### 🔷 Write & Upload (3 tool + 3 chunked) + +``` +upload_file + path: string (obbligatorio, path completo incluso nome file) + content: string (obbligatorio) + encoding: "utf8"|"base64" (default: "utf8") + contentType?: string (MIME type, auto-detect se mancante) + mtime?: number (unix timestamp, X-OC-Mtime header) + → FileMetadata +``` +PUT request. Per file fino a ~10MB di content nel param (limite pratico MCP). +Il content viene decodificato (base64→buffer o utf8→stringa) e inviato come body. + +``` +create_folder + path: string (obbligatorio) + → FileMetadata +``` +MKCOL request. + +``` +bulk_upload + files: Array<{ + path: string, + content: string, + encoding?: "utf8"|"base64", + contentType?: string, + mtime?: number + }> + → Array<{ path: string, error: boolean, etag?: string, errorMessage?: string }> +``` +POST `/remote.php/dav/bulk` con `Content-Type: multipart/related`. Per molti file piccoli. + +``` +chunked_upload_start + path: string (destinazione finale) + totalSize: number (dimensione totale in bytes) + chunkSize?: number (default: 10_485_760 = 10MB, min 5MB max 5GB) + → { uploadId: string, totalChunks: number } +``` +Crea directory in `/uploads/{user}/{uuid}` con MKCOL. Calcola automaticamente il numero di chunk. + +``` +chunked_upload_chunk + uploadId: string (dallo start) + chunkIndex: number (1-based) + content: string (base64 encoded) + → { success: boolean, uploadedSize: number } +``` +PUT chunk in `/uploads/{user}/{uuid}/{chunkIndex}`. Il content arriva come base64 per compatibilità MCP. + +``` +chunked_upload_finish + uploadId: string + mtime?: number (unix timestamp opzionale) + → FileMetadata +``` +MOVE `.file` per assemblare. Il server unisce i chunk nel file finale e fa cleanup. + +> Nota: chunked upload è 3 tool ma logicamente un'unica operazione. L'LLM coordina i 3 step. + +#### 🔷 Move, Copy, Delete (3 tool) + +``` +move_file + source: string (path attuale) + destination: string (path destinazione) + overwrite?: boolean (default: false) + → FileMetadata +``` +MOVE request. Serve anche per rinominare (basta cambiare il nome nel path destinazione). + +``` +copy_file + source: string + destination: string + overwrite?: boolean (default: false) + → FileMetadata +``` +COPY request. + +``` +delete_file + path: string (obbligatorio) + → { success: boolean, path: string } +``` +DELETE request. Il file finisce nel cestino (non eliminazione permanente). + +#### 🔷 Trashbin (4 tool) + +``` +trash_list + → TrashedFile[] +``` +PROPFIND su `/trashbin/{user}/trash`. `TrashedFile` aggiunge: originalName, originalLocation, deletionTime. + +``` +trash_restore + trashPath: string (path nel cestino, come restituito da trash_list) + → { success: boolean, restoredPath: string } +``` +MOVE al folder `/trashbin/{user}/restore`. + +``` +trash_delete + trashPath: string (eliminazione permanente) + → { success: boolean } +``` +DELETE sul item nel cestino. + +``` +trash_empty + → { success: boolean } +``` +DELETE su `/trashbin/{user}/trash`. + +#### 🔷 Favorites & Versions (3 tool) + +``` +set_favorite + path: string + favorite: boolean (true = preferito, false = rimuovi) + → FileMetadata +``` +PROPPATCH con `oc:favorite`. + +``` +get_file_versions + fileId: number (oc:fileid) + → FileVersion[] +``` +PROPFIND su `/versions/{user}/versions/{fileId}`. + +``` +restore_file_version + fileId: number + versionName: string (timestamp della versione) + → { success: boolean } +``` +MOVE al folder `/versions/{user}/restore`. + +--- + +## 4. Tipi TypeScript + +```typescript +// === Core === + +interface NextcloudConfig { + url: string; + username: string; + password: string; +} + +// === File Metadata === + +interface FileMetadata { + name: string; // filename + path: string; // path relativo alla root utente (es. /Documents/file.pdf) + 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 === + +interface TrashedFile extends FileMetadata { + originalName: string; // nc:trashbin-filename + originalLocation: string; // nc:trashbin-original-location + deletionTime: string; // nc:trashbin-deletion-time (unix timestamp) +} + +// === Versioni === + +interface FileVersion { + name: string; // timestamp della versione + size: number; + lastModified: string; + etag?: string; +} + +// === Quota === + +interface QuotaInfo { + used: number; // bytes + available: number; // bytes (-1 = uncomputed, -2 = unknown, -3 = unlimited) +} + +// === Upload === + +interface BulkUploadResult { + path: string; + error: boolean; + etag?: string; + errorMessage?: string; +} + +interface ChunkedUploadSession { + uploadId: string; + destination: string; + totalSize: number; + chunkSize: number; + totalChunks: number; + createdAt: number; // unix timestamp +} + +// === Search === + +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 === + +interface ToolResponse { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +} +``` + +--- + +## 5. Piano di Implementazione Step-by-Step + +### Step 0 — Refactoring strutturale + +**Obiettivo**: Ristrutturare il codebase per renderlo modulare e mantenibile prima di aggiungere 21 nuovi tool. + +**Struttura target**: + +``` +src/ +├── index.ts — Entry point: config, MCP server setup, tool routing (thin) +├── types.ts — Tutte le interfacce e tipi condivisi +├── client.ts — Nextcloud HTTP client (axios wrapper, metodi WebDAV generici) +├── webdav.ts — WebDAV XML builders + response parsers +├── caldav.ts — CalDAV XML builders + parsers (esistente, pulizia minima) +├── utils.ts — Shared utilities (path, mime detect, formatting) +├── tools/ +│ ├── index.ts — Tool registry: definizioni + routing automatico +│ ├── calendar.ts — Tool calendario (estratti da index.ts) +│ ├── tasks.ts — Tool task (estratti da index.ts) +│ ├── notes.ts — Tool note (estratti da index.ts) +│ ├── email.ts — Tool email (estratti da index.ts) +│ └── files.ts — Tool file (nuovi, implementati negli step successivi) +``` + +**Cosa fare nel dettaglio**: + +1. **`src/types.ts`** (nuovo): + - Tutte le interfacce dalla sezione 4 di questo piano + - Esportare `NextcloudConfig`, `FileMetadata`, `ToolResponse`, ecc. + +2. **`src/utils.ts`** (nuovo): + - `normalizePath(path: string): string` — slash management + - `buildDavPath(username: string, relativePath: string): string` — path completo WebDAV + - `buildDavUrl(baseUrl: string, username: string, relativePath: string): string` — URL per header Destination + - `resolveRelativePath(href: string, davBase: string): string` — path relativo da href PROPFIND + - `isTextMimeType(mimeType: string | undefined): boolean` + - `detectMimeType(filename: string): string` — da estensione + - `formatFileSize(bytes: number): string` — umano-readable + - `generateUUID(): string` + +3. **`src/client.ts`** (nuovo): + - Classe `NextcloudClient` che wrappa axios con basic auth + - Due axios instance: uno per XML (default), uno per binary (responseType: arraybuffer) + - Metodi generici per le operazioni WebDAV di basso livello: + ```typescript + propfind(path: string, body: string, depth?: string): Promise + get(path: string, headers?: Record, responseType?: "text"|"arraybuffer"): Promise + put(path: string, data: Buffer|string, headers?: Record): Promise + mkcol(path: string): Promise + delete(path: string): Promise + move(source: string, destination: string, overwrite?: boolean): Promise + copy(source: string, destination: string, overwrite?: boolean): Promise + report(path: string, body: string): Promise + search(body: string): Promise + proppatch(path: string, body: string): Promise + postBulk(body: Buffer, contentType: string): Promise + ``` + - Ogni metodo gestisce errori HTTP (404, 403, 405, 412, 507) con eccezioni tipizzate + - Il client conosce username e baseURL, i metodi ricevono path relativi + +4. **`src/webdav.ts`** (nuovo): + - Builder XML: `buildPropfindBody()`, `buildProppatchBody()`, `buildSearchRequest()`, `buildFavoriteFilterRequest()` + - Parser XML: `parsePropfindResponse()`, `parsePropfindSingleResponse()`, `parseSearchResponse()`, `parseTrashbinResponse()`, `parseVersionsResponse()` + - Funzioni di supporto per estrarre proprietà dai namespace `d:`, `oc:`, `nc:` + +5. **`src/tools/index.ts`** (nuovo): + - Interfaccia `ToolModule`: + ```typescript + interface ToolModule { + definitions: Tool[]; // JSON schema per MCP + handler(name: string, args: any, client: NextcloudClient): Promise; + } + ``` + - Funzione `registerAllTools(modules: ToolModule[]): { tools: Tool[], handler }` che unisce tutte le definizioni e crea il routing handler + - Ogni modulo (calendar, tasks, notes, email, files) esporta un `ToolModule` + +6. **Estrazione tool esistenti**: + - `src/tools/calendar.ts` — `list_calendars`, `get_calendar_events`, `create_calendar_event` + - `src/tools/tasks.ts` — `get_tasks`, `create_task`, `update_task` + - `src/tools/notes.ts` — `get_notes`, `create_note`, `get_note_content` + - `src/tools/email.ts` — `get_emails` + +7. **`src/index.ts`** (ridotto): + - Legge config da env + - Istanzia `NextcloudClient` + - Registra tutti i moduli tool + - Setup MCP server (handlers + transport) + - ~50 righe totali + +**Criteri di accettazione**: +- ✅ Zero regressione: tutti i tool esistenti funzionano identici a prima +- ✅ `npm run build` compila senza errori +- ✅ `ncmcp list_calendars` e `ncmcp get_tasks` funzionano +- ✅ La struttura supporta aggiunta di nuovi tool senza toccare `index.ts` + +--- + +### Step 1 — Modulo `src/webdav.ts` (XML helpers + parser) + +**Obiettivo**: Implementare builder e parser XML per tutte le operazioni file WebDAV. + +**Builder XML**: + +```typescript +// PROPFIND standard — richiedere proprietà specifiche +buildPropfindBody(selectedProps?: string[]): string +// Se selectedProps vuoto: usa il set standard (displayname, getcontenttype, getlastmodified, getetag, getcontentlength, resourcetype, oc:fileid, oc:size, oc:permissions, oc:favorite) + +// PROPFIND esteso — per get_file_info (include owner, checksum, has-preview, etc.) +buildPropfindExtendedBody(): string + +// PROPFIND trashbin — proprietà cestino +buildTrashbinPropfindBody(): string + +// PROPFIND versions — proprietà versioni +buildVersionsPropfindBody(): string + +// PROPPATCH — impostare una proprietà +buildProppatchBody(namespace: string, property: string, value: string): string +// es: buildProppatchBody("oc", "favorite", "1") + +// REPORT — filtro preferiti +buildFavoriteFilterBody(selectedProps?: string[]): string + +// SEARCH — query generica +buildSearchRequest(options: SearchOptions): string +// Costruisce dinamicamente combinando i filtri con +``` + +**Parser XML**: + +```typescript +// PROPFIND response → array FileMetadata (esclude la cartella root dal risultato) +parsePropfindFilesResponse(xml: string, basePath: string): FileMetadata[] + +// PROPFIND Depth:0 response → singolo FileMetadata +parsePropfindSingleFileResponse(xml: string, basePath: string): FileMetadata + +// PROPFIND response con quota → QuotaInfo +parseQuotaResponse(xml: string): QuotaInfo + +// SEARCH response → array FileMetadata +parseSearchResponse(xml: string, basePath: string): FileMetadata[] + +// PROPFIND trashbin → array TrashedFile +parseTrashbinResponse(xml: string): TrashedFile[] + +// PROPFIND versions → array FileVersion +parseVersionsResponse(xml: string): FileVersion[] +``` + +**Funzioni di estrazione proprietà** (usate internamente dai parser): + +```typescript +// Estrae una proprietà da un blocco XML +extractProperty(block: string, namespace: string, property: string): string | null + +// Estrae proprietà booleana +extractBooleanProperty(block: string, namespace: string, property: string): boolean + +// Estrae proprietà numerica +extractNumericProperty(block: string, namespace: string, property: string): number | null +``` + +**Approccio al parsing**: regex-based come in `caldav.ts`. I namespace XML (`d:`, `oc:`, `nc:`) possono avere o meno prefisso, quindi i regex devono gestire entrambi i casi: +- Con prefisso: `image/png` +- Senza prefisso: `image/png` + +**Dipendenze**: nessuna nuova. + +**Criteri di accettazione**: +- ✅ Ogni builder produce XML valido verificabile con curl +- ✅ I parser gestiscono namespace con e senza prefisso +- ✅ I parser non crashano su proprietà mancanti (fallback a undefined/null) +- ✅ `parsePropfindFilesResponse` esclude il primo `` (la cartella root) + +--- + +### Step 2 — Tool Browsing & Discovery + +**Obiettivo**: Implementare i 5 tool di base per esplorare i file. + +**File**: `src/tools/files.ts` (inizio) + +**Tool**: `list_files`, `get_file_info`, `search_files`, `list_favorites`, `get_quota` + +**Implementazione**: + +- **`list_files`**: + 1. Normalizza `path` (default: `/`) + 2. Se `depth === "0"`: singola risorsa + 3. `client.propfind(filesPath, buildPropfindBody(properties), depth)` + 4. `parsePropfindFilesResponse(xml, filesPath)` + 5. Se `depth === "1"`: il primo elemento è la cartella stessa → escludere dal risultato + +- **`get_file_info`**: + 1. `client.propfind(filesPath, buildPropfindExtendedBody(), "0")` + 2. `parsePropfindSingleFileResponse(xml, filesPath)` + 3. Include proprietà estese: owner, share-types, checksum, has-preview, mount-type + +- **`search_files`**: + 1. Costruisci `SearchOptions` dai parametri del tool + 2. `client.search(buildSearchRequest(options))` + 3. `parseSearchResponse(xml, basePath)` + 4. Logica costruzione where-clause: + - `query` → `{query}%` + - `mimeType` → `` con `d:getcontenttype` + - `minSize` → `` con `oc:size` + - `maxSize` → `` con `oc:size` + - `modifiedAfter` → `` con `d:getlastmodified` + - `modifiedBefore` → `` con `d:getlastmodified` + - `favorite === true` → `` con `oc:favorite` = `1` + - Multipli → `` wrapper + - Nessun filtro → `` (solo file) + +- **`list_favorites`**: + 1. `client.report(filesPath, buildFavoriteFilterBody())` + 2. `parsePropfindFilesResponse(xml, filesPath)` + +- **`get_quota`**: + 1. `client.propfind(filesRoot, buildQuotaBody(), "0")` + 2. `parseQuotaResponse(xml)` + +**Test manuali via ncmcp**: +```bash +ncmcp list_files path=/Documents +ncmcp list_files path=/ depth=infinity +ncmcp get_file_info path=/Documents/report.pdf +ncmcp search_files query="foto" mimeType="image/%" path=/Photos limit=10 +ncmcp search_files modifiedAfter="2026-01-01T00:00:00Z" sortBy=size sortOrder=desc limit=5 +ncmcp list_favorites +ncmcp get_quota +``` + +--- + +### Step 3 — Tool Read & Download + +**Obiettivo**: Leggere contenuto file e gestire download (piccoli e grandi). + +**File**: `src/tools/files.ts` (continuazione) + +**Tool**: `read_file`, `download_file`, `download_folder` + +**Dettagli implementativi**: + +- **`read_file`**: + 1. Opzionale: `HEAD` per pre-check `Content-Length` vs `maxSize` (evita di scaricare file enormi) + 2. Se size > maxSize → errore: `"File too large (${size} bytes). Use download_file to get a direct URL."` + 3. `client.get(filesPath, {}, "arraybuffer")` (sempre come buffer per gestire sia testo che binary) + 4. Determina encoding: + - Se `encoding` param = `"base64"` → converti buffer in base64 + - Se `encoding` param = `"utf8"` o non specificato: + - Se `isTextMimeType(response.contentType)` → buffer.toString("utf-8") + - Se binary → buffer.toString("base64"), encoding = "base64" + 5. Ritorna: `{ metadata, content, encoding, size }` + 6. Se maxSize non specificato: default 10MB + +- **`download_file`**: + 1. Non scarica nulla — costruisce l'URL diretto + 2. `url = `${config.url}/remote.php/dav/files/${config.username}${path}`` + 3. Opzionale: `HEAD` per ottenere metadata del file + 4. Ritorna: `{ metadata: FileMetadata, downloadUrl: string }` + +- **`download_folder`**: + 1. Valida che il path sia una cartella (PROPFIND Depth: 0, controlla resourcetype) + 2. Se `files` fornito: costruisce query params `?accept=zip&files=[...]` + 3. `client.get(folderPath, { Accept: `application/${format}` }, "arraybuffer")` + 4. Se response size > maxSize: ritorna URL con query params + 5. Altrimenti: converti buffer in base64, ritorna content inline + 6. maxSize default: 50MB + +**Test manuali**: +```bash +ncmcp read_file path=/Documents/notes.txt +ncmcp read_file path=/Documents/photo.jpg encoding=base64 +ncmcp read_file path=/Documents/huge.zip # dovrebbe suggerire download_file +ncmcp download_file path=/Documents/video.mp4 # ritorna URL +ncmcp download_folder path=/Photos format=zip +ncmcp download_folder path=/Photos files='["IMG_001.jpg","IMG_002.jpg"]' format=zip +``` + +--- + +### Step 4 — Tool Write & Upload (diretto) + +**Obiettivo**: Upload file singoli, crea cartelle, bulk upload. + +**File**: `src/tools/files.ts` (continuazione) + +**Tool**: `upload_file`, `create_folder`, `bulk_upload` + +**Dettagli implementativi**: + +- **`upload_file`**: + 1. Se `encoding === "base64"`: `Buffer.from(content, "base64")` + 2. Se `encoding === "utf8"`: usa content come stringa + 3. Determina `Content-Type`: dal param `contentType` o `detectMimeType(path)` + 4. `client.put(filesPath, buffer, { "Content-Type": ct, "X-OC-Mtime": mtime })` + 5. Dopo upload: `PROPFIND Depth: 0` per ritornare FileMetadata del file creato/aggiornato + 6. Adatto per file fino a ~10MB di content nel param (limite pratico del trasporto MCP) + +- **`create_folder`**: + 1. `client.mkcol(filesPath)` + 2. Gestire 405 (Method Not Allowed = già esiste): ritorna errore chiaro + 3. Dopo creazione: `PROPFIND Depth: 0` per metadata + +- **`bulk_upload`**: + 1. Per ogni file: decodifica content (base64→buffer), determina mime type + 2. Costruisci multipart/related body manualmente: + ``` + --{boundary}\r\n + X-File-Path: /Documents/file1.txt\r\n + X-OC-Mtime: 1675789581\r\n + Content-Length: 1234\r\n + Content-Type: text/plain\r\n + \r\n + {raw file bytes}\r\n + --{boundary}--\r\n + ``` + 3. `client.postBulk(multipartBuffer, "multipart/related; boundary={boundary}")` + 4. Parse response JSON: `{"/path": {"error": false, "etag": "..."}}` + 5. Mappa in `BulkUploadResult[]` + +**Nota su multipart/related**: axios non gestisce multipart/related. Costruire il body manualmente come Buffer concatenando le parti con i boundary. È semplice e dà controllo totale. + +**Test manuali**: +```bash +ncmcp create_folder path=/Documents/NewFolder +ncmcp upload_file path=/Documents/test.txt content="Hello World" +ncmcp upload_file path=/Documents/image.jpg encoding=base64 content="$(base64 -w0 photo.jpg)" +ncmcp bulk_upload files='[{"path":"/test1.txt","content":"a"},{"path":"/test2.txt","content":"b"}]' +``` + +--- + +### Step 5 — Tool Chunked Upload (file grandi) + +**Obiettivo**: Supporto upload resumable per file grandi tramite chunked upload v2. + +**File**: `src/tools/files.ts` (continuazione) + +**Tool**: `chunked_upload_start`, `chunked_upload_chunk`, `chunked_upload_finish` + +**State management**: le sessioni di upload attive vengono tenute in una `Map` in memoria. Non persistente (va bene — scade dopo 24h dal server comunque). + +**Dettagli implementativi**: + +- **`chunked_upload_start`**: + 1. Valida `totalSize` > 0 + 2. Calcola `totalChunks = Math.ceil(totalSize / chunkSize)` + 3. Genera `uploadId = generateUUID()` + 4. `client.mkcol(`/uploads/${username}/${uploadId}`, { Destination: fullDestinationUrl })` + 5. Salva sessione nella mappa: `{ uploadId, destination, totalSize, chunkSize, totalChunks, createdAt }` + 6. Ritorna: `{ uploadId, totalChunks, chunkSize }` + +- **`chunked_upload_chunk`**: + 1. Cerca sessione per `uploadId` → errore se non trovata + 2. Valida `chunkIndex` (1-based, ≤ totalChunks) + 3. Decodifica content: `Buffer.from(content, "base64")` + 4. Valida chunk size: ≥ 5MB per chunk intermedi, ultimo chunk può essere < 5MB + 5. Formatta chunkIndex a 5 cifre: `String(chunkIndex).padStart(5, "0")` + 6. `client.put(`/uploads/${username}/${uploadId}/${chunkIdx}`, buffer, { Destination, "OC-Total-Length": totalSize })` + 7. Ritorna: `{ success: true, uploadedSize: runningTotal }` + +- **`chunked_upload_finish`**: + 1. Cerca sessione → errore se non trovata + 2. `client.move(`/uploads/${username}/${uploadId}/.file`, fullDestinationUrl, { "OC-Total-Length": totalSize, "X-OC-Mtime": mtime })` + 3. Il server assembla i chunk e fa cleanup automatico + 4. Rimuovi sessione dalla mappa + 5. `PROPFIND Depth: 0` sulla destinazione per ritornare FileMetadata + +**Flusso tipico LLM**: +``` +1. chunked_upload_start(path="/Videos/large.mp4", totalSize=150000000) + → { uploadId: "abc-123", totalChunks: 15, chunkSize: 10485760 } + +2. chunked_upload_chunk(uploadId="abc-123", chunkIndex=1, content="") + → { success: true, uploadedSize: 10485760 } + +3. chunked_upload_chunk(uploadId="abc-123", chunkIndex=2, content="") + → { success: true, uploadedSize: 20971520 } + +... (ripeti per tutti i chunk) + +15. chunked_upload_chunk(uploadId="abc-123", chunkIndex=15, content="") + → { success: true, uploadedSize: 150000000 } + +16. chunked_upload_finish(uploadId="abc-123") + → { name: "large.mp4", path: "/Videos/large.mp4", type: "file", size: 150000000, ... } +``` + +**Test manuali**: +```bash +ncmcp chunked_upload_start path=/Videos/test.mp4 totalSize=20971520 +ncmcp chunked_upload_chunk uploadId=UUID chunkIndex=1 content="$(base64 -w0 chunk1.bin)" +ncmcp chunked_upload_chunk uploadId=UUID chunkIndex=2 content="$(base64 -w0 chunk2.bin)" +ncmcp chunked_upload_finish uploadId=UUID +``` + +--- + +### Step 6 — Tool Move, Copy, Delete + +**Obiettivo**: Operazioni di spostamento, copia ed eliminazione. + +**File**: `src/tools/files.ts` (continuazione) + +**Tool**: `move_file`, `copy_file`, `delete_file` + +**Dettagli implementativi**: + +- **`move_file`**: + 1. `client.move(sourcePath, buildDavUrl(destination), overwrite)` + 2. Header `Overwrite: F` (default) o `Overwrite: T` + 3. Gestire 412 (Precondition Failed) → destinazione esiste e overwrite=false + 4. Gestire 404 (Not Found) → sorgente non esiste + 5. Dopo: PROPFIND sulla destinazione per metadata aggiornati + +- **`copy_file`**: + 1. Stessa logica di move ma con `client.copy()` + +- **`delete_file`**: + 1. `client.delete(filesPath)` + 2. Il file finisce nel cestino (non eliminazione permanente) + 3. Gestire 404, 403 + +**Test manuali**: +```bash +ncmcp move_file source=/Documents/old.txt destination=/Documents/new.txt +ncmcp move_file source=/Documents/file.txt destination=/Archive/file.txt +ncmcp copy_file source=/template.docx destination=/Documents/new-doc.docx +ncmcp delete_file path=/Documents/old-file.txt +``` + +--- + +### Step 7 — Tool Trashbin + +**Obiettivo**: Gestione completa del cestino. + +**File**: `src/tools/files.ts` (continuazione) + +**Tool**: `trash_list`, `trash_restore`, `trash_delete`, `trash_empty` + +**Dettagli implementativi**: + +- **`trash_list`**: + 1. `client.propfind(`/trashbin/${username}/trash`, buildTrashbinPropfindBody(), "1")` + 2. `parseTrashbinResponse(xml)` → `TrashedFile[]` + 3. Il parser estrae: `nc:trashbin-filename`, `nc:trashbin-original-location`, `nc:trashbin-deletion-time` + proprietà standard + +- **`trash_restore`**: + 1. `client.move(`/trashbin/${username}/trash/${item}`, `${baseUrl}/trashbin/${username}/restore`)` + 2. Il server ripristina alla posizione originale automaticamente + 3. Gestire 404 (già eliminato permanentemente) + +- **`trash_delete`**: + 1. `client.delete(`/trashbin/${username}/trash/${item}`)` → eliminazione permanente + +- **`trash_empty`**: + 1. `client.delete(`/trashbin/${username}/trash`)` → svuota tutto + +**Test manuali**: +```bash +ncmcp trash_list +ncmcp trash_restore trashPath=/Documents/old-file.txt +ncmcp trash_delete trashPath=/Documents/old-file.txt +ncmcp trash_empty +``` + +--- + +### Step 8 — Tool Favorites & Versions + +**Obiettivo**: Preferiti e versioning file. + +**File**: `src/tools/files.ts` (continuazione) + +**Tool**: `set_favorite`, `get_file_versions`, `restore_file_version` + +**Dettagli implementativi**: + +- **`set_favorite`**: + 1. `client.proppatch(filesPath, buildProppatchBody("oc", "favorite", favorite ? "1" : "0"))` + 2. PROPFIND Depth: 0 per metadata aggiornato + +- **`get_file_versions`**: + 1. `client.propfind(`/versions/${username}/versions/${fileId}`, buildVersionsPropfindBody(), "1")` + 2. `parseVersionsResponse(xml)` → `FileVersion[]` + +- **`restore_file_version`**: + 1. `client.move(`/versions/${username}/versions/${fileId}/${versionName}`, `${baseUrl}/versions/${username}/restore`)` + +**Test manuali**: +```bash +ncmcp set_favorite path=/Documents/important.pdf favorite=true +ncmcp set_favorite path=/Documents/important.pdf favorite=false +ncmcp get_file_versions fileId=12345 +ncmcp restore_file_version fileId=12345 versionName="1675789581" +``` + +--- + +### Step 9 — Aggiornamento `ncmcp.mjs` + +**Obiettivo**: Aggiornare il wrapper CLI per supportare tutti i nuovi tool. + +**Modifiche**: +1. Aggiornare la lista tool nel help text (hardcoded o dinamica) +2. Supportare `path` come argomento posizionale: `ncmcp read_file /Documents/file.txt` ≡ `ncmcp read_file path=/Documents/file.txt` +3. Supportare `content` da stdin per upload: `echo "content" | ncmcp upload_file path=/test.txt` +4. Supportare `content` da file locale: `ncmcp upload_file path=/remote/test.txt @./local/file.txt` (legge il file, lo converte in base64, lo passa come content) +5. Per `bulk_upload`: accettare un JSON file: `ncmcp bulk_upload @./files.json` +6. Per `download_file`: aggiungere flag `--curl` che stampa il comando curl da eseguire + +--- + +### Step 10 — Test & Documentazione + +**Obiettivo**: Verifica completa e aggiornamento README. + +**Cosa fare**: +1. Test manuale di ogni tool tramite `ncmcp` e `mcporter` +2. Test edge cases: + - Path con spazi e caratteri speciali (urlencode) + - File con MIME type insoliti + - Cartelle vuote + - File grandi (chunked upload end-to-end) + - Cestino vuoto + - Ricerca senza risultati + - Spostamento con destinazione esistente (overwrite true/false) + - Chunked upload con sessione scaduta (24h) +3. Aggiornare README.md con: + - Lista completa dei tool (tutti i 21) + - Tabella quick-reference (tool → operazione WebDAV) + - Esempi d'uso per ogni categoria + - Note sui limiti di dimensione MCP +4. Aggiornare `test-connection.js` per verificare anche le operazioni file + +--- + +## 6. Strategia Upload/Download per Dimensioni + +Diagramma decisionale per l'LLM: + +``` +UPLOAD FILE +├─ Content disponibile nel param (≤~10MB) → upload_file (PUT diretto) +├─ Content troppo grande per il param → chunked_upload_start/chunk/finish +└─ Molti file piccoli → bulk_upload (multipart/related) + +READ FILE +├─ File ≤ maxSize (default 10MB) → read_file (content inline, utf8 o base64) +└─ File > maxSize → download_file (URL diretto, scaricare fuori banda) + +DOWNLOAD FOLDER +├─ Cartella ≤ maxSize (default 50MB) → download_folder (ZIP/TAR inline, base64) +└─ Cartella > maxSize → download_folder (URL diretto con query params) +``` + +**Nota**: i limiti (`maxSize`) sono configurabili per-tool. Non sono limiti di Nextcloud (che supporta qualsiasi dimensione), ma limiti pratici del trasporto MCP dove tutto passa come JSON su stdio. diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..66a0da1 --- /dev/null +++ b/src/client.ts @@ -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 { + 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, + responseType: "text" | "arraybuffer" = "text" + ): Promise { + 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 + ): Promise { + return this.binaryClient.put(path, data, { headers }); + } + + async mkcol(path: string, headers?: Record): Promise { + return this.xmlClient.request({ + method: "MKCOL", + url: path, + headers, + }); + } + + async delete(path: string): Promise { + return this.xmlClient.delete(path); + } + + async move( + source: string, + destination: string, + overwrite: boolean = false + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + return this.xmlClient.post(path, data, { headers }); + } + + async postBulk(body: Buffer, contentType: string): Promise { + 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(/\/$/, ""); + } +} diff --git a/src/index.ts b/src/index.ts index eb5a515..e5a7b2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,1035 +5,90 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { CallToolRequestSchema, ListToolsRequestSchema, - Tool, } from "@modelcontextprotocol/sdk/types.js"; -import axios, { AxiosInstance } from "axios"; -import { format } from "date-fns"; -import { - CalendarInfo, - buildCalendarDiscoveryPropfindBody, - buildCalendarEventsReportBody, - buildTasksReportBody, - dedupeEvents, - formatICalDate, - formatICalDateTimeUtc, - getCalDAVXmlHeaders, - getEventSortTimestamp, - parseCalendarsFromPROPFIND, - parseEventsFromCalDAV, - parseISODateOnlyLocalMidnight, - parseTasksFromCalDAV, - resolveCalendarSelectors, - stripEventInternalFields, -} from "./caldav.js"; +import { NextcloudClient } from "./client.js"; +import { NextcloudConfig } from "./types.js"; +import { registerAllTools } from "./tools/index.js"; +import { calendarToolModule } from "./tools/calendar.js"; +import { tasksToolModule } from "./tools/tasks.js"; +import { notesToolModule } from "./tools/notes.js"; +import { emailToolModule } from "./tools/email.js"; +import { filesToolModule } from "./tools/files.js"; -interface NextcloudConfig { - url: string; - username: string; - password: string; // App password recommended +function loadConfig(): NextcloudConfig { + return { + url: process.env.NEXTCLOUD_URL || "", + username: process.env.NEXTCLOUD_USERNAME || "", + password: process.env.NEXTCLOUD_PASSWORD || "", + }; } -class NextcloudMCPServer { - private server: Server; - private axiosInstance: AxiosInstance; - private config: NextcloudConfig; - private debugEnabled: boolean; - - constructor(config: NextcloudConfig) { - this.config = config; - this.debugEnabled = process.env.DEBUG_NEXTCLOUD_MCP === "1"; - this.server = new Server( - { - name: "nextcloud-mcp-server", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - } +function validateConfig(config: NextcloudConfig): void { + if (!config.url || !config.username || !config.password) { + console.error( + "Error: NEXTCLOUD_URL, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD environment variables are required" ); - - // Create axios instance with basic auth - this.axiosInstance = axios.create({ - baseURL: config.url, - auth: { - username: config.username, - password: config.password, - }, - headers: { - "Content-Type": "application/xml", - "Accept": "application/xml", - }, - }); - - this.setupHandlers(); - this.setupErrorHandling(); - } - - private setupErrorHandling(): void { - this.server.onerror = (error) => { - console.error("[MCP Error]", error); - }; - - process.on("SIGINT", async () => { - await this.server.close(); - process.exit(0); - }); - } - - private setupHandlers(): void { - // List available tools - this.server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: this.getTools(), - }; - }); - - // Handle tool calls - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - try { - switch (name) { - case "get_tasks": - return await this.getTasks(args as any); - case "create_task": - return await this.createTask(args as any); - case "update_task": - return await this.updateTask(args as any); - case "get_calendar_events": - return await this.getCalendarEvents(args as any); - case "list_calendars": - return await this.listCalendars(); - case "create_calendar_event": - return await this.createCalendarEvent(args as any); - case "get_notes": - return await this.getNotes(args as any); - case "create_note": - return await this.createNote(args as any); - case "get_note_content": - return await this.getNoteContent(args as any); - case "get_emails": - return await this.getEmails(args as any); - default: - throw new Error(`Unknown tool: ${name}`); - } - } catch (error: any) { - return { - content: [ - { - type: "text", - text: `Error: ${error.message}`, - }, - ], - isError: true, - }; - } - }); - } - - private getTools(): Tool[] { - return [ - // Tasks tools - { - 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"], - }, - }, - // Calendar tools - { - 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: "list_calendars", - description: - "List available Nextcloud CalDAV calendars with href and supported components", - inputSchema: { - type: "object", - properties: {}, - }, - }, - { - 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"], - }, - }, - // Notes tools - { - 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"], - }, - }, - // Email tools - { - 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, - }, - }, - }, - }, - ]; - } - - // ========== TASKS METHODS ========== - private async getTasks(args: any) { - const status = args.status || "all"; - const limit = args.limit || 50; - - try { - // CalDAV REPORT request to get tasks - const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/`; - - const requestBody = buildTasksReportBody(); - - const response = await this.axiosInstance.request({ - method: "REPORT", - url: caldavPath, - data: requestBody, - headers: getCalDAVXmlHeaders("1"), - }); - - const tasks = parseTasksFromCalDAV(response.data, status, limit); - - return { - content: [ - { - type: "text", - text: JSON.stringify(tasks, null, 2), - }, - ], - }; - } catch (error: any) { - throw new Error(`Failed to fetch tasks: ${error.message}`); - } - } - - private async createTask(args: any) { - const { summary, description, due, priority } = args; - const uid = this.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/${this.config.username}/tasks/${uid}.ics`; - - await this.axiosInstance.put(caldavPath, vtodo, { - headers: { - "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}`); - } - } - - private async updateTask(args: any) { - const { taskId, summary, status, percentComplete } = args; - - // First, fetch the existing task - const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/${taskId}.ics`; - - try { - const response = await this.axiosInstance.get(caldavPath); - let vtodo = response.data; - - // Update fields - 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` - ); - } - } - - // Update LAST-MODIFIED - vtodo = vtodo.replace( - /LAST-MODIFIED:.*/, - `LAST-MODIFIED:${formatICalDateTimeUtc(new Date())}` - ); - - await this.axiosInstance.put(caldavPath, vtodo, { - headers: { - "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}`); - } - } - - // ========== CALENDAR METHODS ========== - private async listCalendars() { - const calendars = await this.discoverCalendars(); - return { - content: [ - { - type: "text", - text: JSON.stringify(calendars, null, 2), - }, - ], - }; - } - - private async getCalendarEvents(args: any) { - 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 this.discoverCalendars(); - 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 { - this.debugLog(`Querying calendar href: ${calendar.href}`); - this.debugLog(`REPORT body for ${calendar.href}:\n${requestBody}`); - - const response = await this.axiosInstance.request({ - method: "REPORT", - url: calendar.href, - data: requestBody, - headers: getCalDAVXmlHeaders("1"), - }); - - const responseBody = String(response.data ?? ""); - this.debugLog( - `REPORT response for ${calendar.href}: status=${response.status} body-preview=${responseBody.slice( - 0, - 500 - )}` - ); - - const events = parseEventsFromCalDAV( - responseBody, - calendar.href, - (message) => this.debugLog(message) - ).map((event) => ({ - ...event, - calendarName: calendar.name, - calendarHref: calendar.href, - })); - mergedEvents.push(...events); - } catch (error: any) { - requestErrors.push(`${calendar.href}: ${error.message}`); - this.debugLog(`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}`); - } - } - - private async createCalendarEvent(args: any) { - const { - summary, - description, - location, - allDay, - startDateTime, - endDateTime, - startDate, - endDate, - reminderMinutesBefore, - reminderDateTime, - reminderDescription, - } = args; - const uid = this.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 args.reminderAction === "string" && args.reminderAction.trim() - ? args.reminderAction.trim().toUpperCase() - : "DISPLAY"; - const alarmDescription = this.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:${this.escapeICalText(summary)} -${dtStartLine} -${dtEndLine} -CREATED:${formatICalDateTimeUtc(new Date())}`; - - if (description) { - vevent += `\nDESCRIPTION:${this.escapeICalText(description)}`; - } - if (location) { - vevent += `\nLOCATION:${this.escapeICalText(location)}`; - } - vevent += alarmBlock; - - vevent += `\nEND:VEVENT -END:VCALENDAR`; - - try { - const caldavPath = `/remote.php/dav/calendars/${this.config.username}/personal/${uid}.ics`; - - await this.axiosInstance.put(caldavPath, vevent, { - headers: { - "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}`); - } - } - - // ========== NOTES METHODS ========== - private async getNotes(args: any) { - const limit = args.limit || 50; - - try { - const response = await this.axiosInstance.get( - `/index.php/apps/notes/api/v1/notes`, - { - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - } - ); - - const notes = response.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}`); - } - } - - private async createNote(args: any) { - const { title, content, category } = args; - - // Nextcloud Notes uses the first line as title - const noteContent = title ? `${title}\n\n${content}` : content; - - try { - const payload: any = { - content: noteContent, - }; - - if (category) { - payload.category = category; - } - - const response = await this.axiosInstance.post( - `/index.php/apps/notes/api/v1/notes`, - payload, - { - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - } - ); - - return { - content: [ - { - type: "text", - text: `Note created successfully with ID: ${response.data.id}`, - }, - ], - }; - } catch (error: any) { - throw new Error(`Failed to create note: ${error.message}`); - } - } - - private async getNoteContent(args: any) { - const { noteId } = args; - - try { - const response = await this.axiosInstance.get( - `/index.php/apps/notes/api/v1/notes/${noteId}`, - { - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - } - ); - - return { - content: [ - { - type: "text", - text: JSON.stringify(response.data, null, 2), - }, - ], - }; - } catch (error: any) { - throw new Error(`Failed to fetch note: ${error.message}`); - } - } - - // ========== EMAIL METHODS ========== - private async getEmails(args: any) { - const accountId = args.accountId || 0; - const limit = args.limit || 20; - - try { - // Get mailboxes first - const mailboxesResponse = await this.axiosInstance.get( - `/index.php/apps/mail/api/accounts/${accountId}/mailboxes`, - { - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - } - ); - - // Find INBOX - const inbox = mailboxesResponse.data.find( - (mb: any) => mb.specialRole === "inbox" - ); - - if (!inbox) { - throw new Error("Inbox not found"); - } - - // Get messages from inbox - const messagesResponse = await this.axiosInstance.get( - `/index.php/apps/mail/api/messages?mailboxId=${inbox.id}`, - { - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - } - ); - - const emails = messagesResponse.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}`); - } - } - - // ========== UTILITY METHODS ========== - private generateUID(): string { - return `${Date.now()}-${Math.random().toString(36).substring(7)}`; - } - - private 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, "\\,"); - } - - private debugLog(message: string): void { - if (this.debugEnabled) { - console.error(`[DEBUG_NEXTCLOUD_MCP] ${message}`); - } - } - - private async discoverCalendars(): Promise { - const calendarsRoot = `/remote.php/dav/calendars/${this.config.username}/`; - const requestBody = buildCalendarDiscoveryPropfindBody(); - - this.debugLog(`PROPFIND calendars root: ${calendarsRoot}`); - - const response = await this.axiosInstance.request({ - method: "PROPFIND", - url: calendarsRoot, - data: requestBody, - headers: getCalDAVXmlHeaders("1"), - }); - - const responseBody = String(response.data ?? ""); - this.debugLog( - `PROPFIND response status=${response.status} body-preview=${responseBody.slice( - 0, - 500 - )}` - ); - - return parseCalendarsFromPROPFIND(responseBody, calendarsRoot); - } - - async run(): Promise { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - console.error("Nextcloud MCP Server running on stdio"); + process.exit(1); } } -// Main execution -const config: NextcloudConfig = { - url: process.env.NEXTCLOUD_URL || "", - username: process.env.NEXTCLOUD_USERNAME || "", - password: process.env.NEXTCLOUD_PASSWORD || "", -}; +async function main(): Promise { + const config = loadConfig(); + validateConfig(config); -if (!config.url || !config.username || !config.password) { - console.error( - "Error: NEXTCLOUD_URL, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD environment variables are required" + const client = new NextcloudClient(config); + + const { tools, handler } = registerAllTools([ + calendarToolModule, + tasksToolModule, + notesToolModule, + emailToolModule, + filesToolModule, + ]); + + const server = new Server( + { + name: "nextcloud-mcp-server", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } ); - process.exit(1); + + server.onerror = (error) => { + console.error("[MCP Error]", error); + }; + + process.on("SIGINT", async () => { + await server.close(); + process.exit(0); + }); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + try { + return await handler(name, args, client); + } catch (error: any) { + return { + content: [{ type: "text", text: `Error: ${error.message}` }], + isError: true, + }; + } + }); + + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Nextcloud MCP Server running on stdio"); } -const server = new NextcloudMCPServer(config); -server.run().catch(console.error); +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/tools/calendar.ts b/src/tools/calendar.ts new file mode 100644 index 0000000..fe87da2 --- /dev/null +++ b/src/tools/calendar.ts @@ -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 { + 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 { + const calendars = await discoverCalendars(client); + return { + content: [{ type: "text", text: JSON.stringify(calendars, null, 2) }], + }; +} + +async function getCalendarEvents(args: any, client: NextcloudClient): Promise { + 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 { + 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 { + 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); +} diff --git a/src/tools/email.ts b/src/tools/email.ts new file mode 100644 index 0000000..e200a72 --- /dev/null +++ b/src/tools/email.ts @@ -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 { + if (name === "get_emails") { + return getEmails(args, client); + } + throw new Error(`Unknown email tool: ${name}`); + }, +}; + +// --- Implementation --- + +async function getEmails(args: any, client: NextcloudClient): Promise { + 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}`); + } +} diff --git a/src/tools/files.ts b/src/tools/files.ts new file mode 100644 index 0000000..3605eb9 --- /dev/null +++ b/src/tools/files.ts @@ -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 { + return { + content: [{ type: "text", text: "File tools not yet implemented." }], + isError: true, + }; + }, +}; diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..67dcd21 --- /dev/null +++ b/src/tools/index.ts @@ -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; +} + +export function registerAllTools(modules: ToolModule[]): { + tools: Tool[]; + handler: (name: string, args: any, client: NextcloudClient) => Promise; +} { + const tools: Tool[] = []; + const handlers = new Map Promise>(); + + 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); + }, + }; +} diff --git a/src/tools/notes.ts b/src/tools/notes.ts new file mode 100644 index 0000000..29129bd --- /dev/null +++ b/src/tools/notes.ts @@ -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 { + 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 { + 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 { + 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 { + 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}`); + } +} diff --git a/src/tools/tasks.ts b/src/tools/tasks.ts new file mode 100644 index 0000000..f5590e2 --- /dev/null +++ b/src/tools/tasks.ts @@ -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 { + 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 { + 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 { + 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 { + 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)}`; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fd4a5a7 --- /dev/null +++ b/src/types.ts @@ -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; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..1184750 --- /dev/null +++ b/src/utils.ts @@ -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 = { + ".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, + }; +} diff --git a/src/webdav.ts b/src/webdav.ts new file mode 100644 index 0000000..cb089c0 --- /dev/null +++ b/src/webdav.ts @@ -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") + : ` + + + + + + + + + `; + + return ` + + +${props} + +`; +} + +export function buildPropfindExtendedBody(): string { + return ` + + + + + + + + + + + + + + + + + +`; +} + +export function buildTrashbinPropfindBody(): string { + return ` + + + + + + + + + + + + + + + +`; +} + +export function buildVersionsPropfindBody(): string { + return ` + + + + + + + +`; +} + +export function buildProppatchBody(namespace: string, property: string, value: string): string { + return ` + + + + <${namespace}:${property}>${value} + + +`; +} + +export function buildFavoriteFilterBody(selectedProps?: string[]): string { + const props = selectedProps && selectedProps.length > 0 + ? selectedProps.map((p) => ` <${p} />`).join("\n") + : ` + + + + + + + + + `; + + return ` + + +${props} + + + 1 + +`; +} + +export function buildSearchRequest(options: SearchOptions): string { + const filters: string[] = []; + + if (options.query) { + filters.push(` + + ${escapeXml(options.query)}% + `); + } + if (options.mimeType) { + filters.push(` + + ${escapeXml(options.mimeType)} + `); + } + if (options.minSize !== undefined) { + filters.push(` + + ${options.minSize} + `); + } + if (options.maxSize !== undefined) { + filters.push(` + + ${options.maxSize} + `); + } + if (options.modifiedAfter) { + filters.push(` + + ${escapeXml(options.modifiedAfter)} + `); + } + if (options.modifiedBefore) { + filters.push(` + + ${escapeXml(options.modifiedBefore)} + `); + } + if (options.favorite === true) { + filters.push(` + + 1 + `); + } + + let whereClause: string; + if (filters.length === 0) { + whereClause = ` + + `; + } else if (filters.length === 1) { + whereClause = ` \n${filters[0]}\n `; + } else { + whereClause = ` \n \n${filters.join("\n")}\n \n `; + } + + const scopePath = normalizePath(options.path || "/"); + const scope = `/files/${escapeXml(scopePath)}`; + + const orderby = options.sortBy + ? ` + + + + + ` + : ""; + + const limit = options.limit !== undefined + ? ` + ${options.limit} + ` + : ""; + + return ` + + + + + + + + + + + + + + + + + + + ${scope} + infinity + + +${whereClause} +${orderby} +${limit} + +`; +} + +// --- XML Parsers (regex-based, like caldav.ts) --- + +function extractProperty(block: string, namespace: string, property: string): string | null { + const regex = new RegExp(`<(?:${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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .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); + // basePath is like /remote.php/dav/files/user/ + if (decoded.startsWith(basePath)) { + return decoded.slice(basePath.length) || "/"; + } + return decoded; +}