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

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

Zero regression on existing tools. Verified: list_calendars, get_notes, create_note, get_note_content all functional.
2026-05-11 13:43:36 +02:00

1036 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <tool> [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 | 5MB5GB (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 `<d:where>` 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<string>
get(path: string, headers?: Record<string, string>, responseType?: "text"|"arraybuffer"): Promise<any>
put(path: string, data: Buffer|string, headers?: Record<string, string>): Promise<any>
mkcol(path: string): Promise<any>
delete(path: string): Promise<any>
move(source: string, destination: string, overwrite?: boolean): Promise<any>
copy(source: string, destination: string, overwrite?: boolean): Promise<any>
report(path: string, body: string): Promise<string>
search(body: string): Promise<string>
proppatch(path: string, body: string): Promise<string>
postBulk(body: Buffer, contentType: string): Promise<any>
```
- 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<ToolResponse>;
}
```
- 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 <d:where> combinando i filtri con <d:and>
```
**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 <response> 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: `<d:getcontenttype>image/png</d:getcontenttype>`
- Senza prefisso: `<getcontenttype xmlns="DAV:">image/png</getcontenttype>`
**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 `<response>` (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` → `<d:like><d:prop><d:displayname/></d:prop><d:literal>{query}%</d:literal></d:like>`
- `mimeType` → `<d:eq>` con `d:getcontenttype`
- `minSize` → `<d:gt>` con `oc:size`
- `maxSize` → `<d:lt>` con `oc:size`
- `modifiedAfter` → `<d:gt>` con `d:getlastmodified`
- `modifiedBefore` → `<d:lt>` con `d:getlastmodified`
- `favorite === true` → `<d:eq>` con `oc:favorite` = `1`
- Multipli → `<d:and>` wrapper
- Nessun filtro → `<d:not><d:is-collection/></d:not>` (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<string, ChunkedUploadSession>` 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="<base64 10MB>")
→ { success: true, uploadedSize: 10485760 }
3. chunked_upload_chunk(uploadId="abc-123", chunkIndex=2, content="<base64 10MB>")
→ { success: true, uploadedSize: 20971520 }
... (ripeti per tutti i chunk)
15. chunked_upload_chunk(uploadId="abc-123", chunkIndex=15, content="<base64 ~4.3MB>")
→ { 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.