diff --git a/README.md b/README.md index 2d96887..e804ceb 100644 --- a/README.md +++ b/README.md @@ -1,285 +1,273 @@ -# Nextcloud MCP Server +# nextcloud-mcp -A Model Context Protocol (MCP) server that integrates with Nextcloud to provide access to: -- **Tasks** (via CalDAV) -- **Calendar Events** (via CalDAV) -- **Notes** (via Notes API) -- **Emails** (via Mail API) +MCP (Model Context Protocol) server for Nextcloud — browse, read, write, and manage files, calendars, tasks, notes, and email via WebDAV/CalDAV/REST APIs. ## Features -### Tasks -- ✅ Get tasks (filter by status: all/open/completed) -- ✅ Create new tasks with due dates and priorities -- ✅ Update tasks (mark complete, change summary, etc.) +- **21 file management tools** — browse, search, read, upload, move, copy, delete, trashbin, favorites, versions +- **Calendar tools** — list calendars, get/create events (CalDAV) +- **Task tools** — get/create/update tasks (CalDAV VTODO) +- **Note tools** — get/create notes (Nextcloud Notes API) +- **Email tool** — get inbox emails (Nextcloud Mail API) +- **Smart size routing** — small files inline (≤10MB), large files via direct URL or chunked upload +- **CLI wrapper** — `ncmcp` command for quick testing and scripting -### Calendar -- ✅ Get calendar events with date range filtering -- ✅ Query one, many, or all VEVENT calendars automatically -- ✅ Discover available calendars and supported components -- ✅ Create new calendar events with details and location +## Quick Start -### Notes -- ✅ Get all notes -- ✅ Create new notes with markdown support -- ✅ Get specific note content by ID +### 1. Configure -### Email -- ✅ Get emails from inbox -- 📧 Requires Nextcloud Mail app configured +Create `.env` in the project root: -## Prerequisites +```env +NEXTCLOUD_URL=https://your-nextcloud.example.com +NEXTCLOUD_USERNAME=your_username +NEXTCLOUD_PASSWORD=your_app_password +``` -1. **Nextcloud Instance** (any recent version with CalDAV support) -2. **Required Nextcloud Apps**: - - Tasks (for task management) - - Calendar (for events) - - Notes (for note-taking) - - Mail (optional, for email access) +> Use an **app password** (Settings → Security → App passwords) instead of your main password. -3. **App Password**: Generate in Nextcloud Settings > Security > Devices & sessions - -## Installation +### 2. Build ```bash npm install npm run build ``` -## Configuration - -1. Copy the environment template: -```bash -cp .env.example .env -``` - -2. Edit `.env` with your Nextcloud credentials: -```env -NEXTCLOUD_URL=https://your-nextcloud.com -NEXTCLOUD_USERNAME=your-username -NEXTCLOUD_PASSWORD=your-app-password -``` - -⚠️ **Important**: Always use an app password, never your main Nextcloud password! - -## Usage - -### Testing Locally +### 3. Test Connection ```bash -# Development mode (auto-reload) -npm run dev - -# Production mode -npm run start +node ncmcp.mjs list_files path=/ +node ncmcp.mjs get_quota ``` -### Using with Claude Desktop +### 4. Use with MCP (mcporter) -Add this to your Claude Desktop configuration file: - -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +Add to your `mcporter.json`: ```json { - "mcpServers": { - "nextcloud": { - "command": "node", - "args": ["/path/to/nextcloud-mcp/build/index.js"], - "env": { - "NEXTCLOUD_URL": "https://your-nextcloud.com", - "NEXTCLOUD_USERNAME": "your-username", - "NEXTCLOUD_PASSWORD": "your-app-password" - } + "nextcloud": { + "command": "node", + "args": ["path/to/nextcloud-mcp/build/index.js"], + "env": { + "NEXTCLOUD_URL": "https://your-nextcloud.example.com", + "NEXTCLOUD_USERNAME": "your_username", + "NEXTCLOUD_PASSWORD": "your_app_password" } } } ``` -Or use the development version with tsx: +Then use: `mcporter call nextcloud.list_files --args '{"path":"/"}'` -```json -{ - "mcpServers": { - "nextcloud": { - "command": "npx", - "args": ["-y", "tsx", "/path/to/nextcloud-mcp/src/index.ts"], - "env": { - "NEXTCLOUD_URL": "https://your-nextcloud.com", - "NEXTCLOUD_USERNAME": "your-username", - "NEXTCLOUD_PASSWORD": "your-app-password" - } - } - } -} +## CLI Usage (ncmcp) + +```bash +ncmcp [key=value ...] [positionalPath] [@file] [--curl] ``` -Restart Claude Desktop to load the MCP server. +**Examples:** -## Available Tools +```bash +# List files +ncmcp list_files path=/Documents -Once connected, Claude can use these tools: +# Read a file +ncmcp read_file path=/Documents/notes.txt -### Tasks -- `get_tasks` - Retrieve tasks (filter by status, limit results) -- `create_task` - Create new task with summary, description, due date, priority -- `update_task` - Update existing task (mark complete, change details) +# Upload a file +ncmcp upload_file path=/Documents/new.txt content="Hello World" +ncmcp upload_file path=/Documents/photo.jpg @./local-photo.jpg -### Calendar -- `list_calendars` - List available calendars (`name`, `href`, `components`) -- `get_calendar_events` - Get events in date range across selected calendars -- `create_calendar_event` - Create new event with details +# Download (get URL) +ncmcp download_file path=/Documents/video.mp4 +ncmcp download_file path=/Documents/video.mp4 --curl # prints curl command -`get_calendar_events` supports: -- `startDate` / `endDate` (YYYY-MM-DD) -- `calendar` (single calendar name or href) -- `calendars` (array of names/hrefs) -- `includeAllCalendars` (default `true`, queries all VEVENT calendars when no selectors are provided) -- `limit` +# Search +ncmcp search_files query="report" mimeType="application/pdf" limit=10 -### Notes -- `get_notes` - List all notes -- `create_note` - Create new note with markdown -- `get_note_content` - Get full content of specific note +# Pipe content +echo "file contents" | ncmcp upload_file path=/test.txt +``` -### Email -- `get_emails` - Retrieve recent emails from inbox +## Tool Reference -## Example Prompts for Claude +### 🔷 Browsing & Discovery -Once the MCP server is connected, you can ask Claude: +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `list_files` | List directory contents | `path` (default: `/`), `depth` (`0`/`1`/`infinity`) | +| `get_file_info` | Get detailed file/folder metadata | `path` (required) | +| `search_files` | Search by name, mime, size, date | `query`, `mimeType`, `minSize`, `maxSize`, `sortBy`, `limit` | +| `list_favorites` | List favorited files | `path` (scope) | +| `get_quota` | Get storage usage | — | + +### 🔷 Read & Download + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `read_file` | Read file content (text or base64) | `path`, `encoding` (`utf8`/`base64`), `maxSize` (default 10MB) | +| `download_file` | Get direct download URL | `path`, `metadata` (default: true) | +| `download_folder` | Download folder as ZIP/TAR | `path`, `format` (`zip`/`tar`), `files` (subset), `maxSize` (default 50MB) | + +### 🔷 Write & Upload + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `upload_file` | Upload file via PUT | `path`, `content`, `encoding`, `contentType`, `mtime` | +| `create_folder` | Create a new folder | `path` | +| `bulk_upload` | Upload multiple files at once | `files[]` (path, content, encoding, contentType) | + +### 🔷 Chunked Upload (Large Files) + +For files too large for a single `upload_file` call (>10MB content in parameter). + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `chunked_upload_start` | Start upload session | `path`, `totalSize`, `chunkSize` (default 10MB) | +| `chunked_upload_chunk` | Upload one chunk (base64) | `uploadId`, `chunkIndex` (1-based), `content` | +| `chunked_upload_finish` | Assemble final file | `uploadId`, `mtime` | + +**Flow:** Start → upload chunks (1..N) → finish. All steps must be in the same session (in-memory state). + +### 🔷 Move, Copy, Delete + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `move_file` | Move/rename file or folder | `source`, `destination`, `overwrite` | +| `copy_file` | Copy file or folder | `source`, `destination`, `overwrite` | +| `delete_file` | Delete (moves to trashbin) | `path` | + +### 🔷 Trashbin + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `trash_list` | List deleted items | — | +| `trash_restore` | Restore from trash | `trashPath` (from `trash_list`) | +| `trash_delete` | Permanently delete | `trashPath` | +| `trash_empty` | Empty entire trashbin | — (⚠️ destructive) | + +### 🔷 Favorites & Versions + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `set_favorite` | Toggle favorite status | `path`, `favorite` (boolean) | +| `get_file_versions` | List file versions | `fileId` (oc:fileid) | +| `restore_file_version` | Restore a version | `fileId`, `versionName` | + +### 📅 Calendar + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `list_calendars` | List CalDAV calendars | — | +| `get_calendar_events` | Get events in date range | `startDate`, `endDate`, `calendar`, `limit` | +| `create_calendar_event` | Create event | `summary`, `startDateTime`/`endDateTime`, `allDay`, `location`, `reminderMinutesBefore` | + +### ✅ Tasks + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `get_tasks` | Get tasks (VTODO) | `status` (`all`/`open`/`completed`), `limit` | +| `create_task` | Create task | `summary`, `description`, `due`, `priority` | +| `update_task` | Update task | `taskId`, `summary`, `status`, `percentComplete` | + +### 📝 Notes + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `get_notes` | List notes | `limit` | +| `create_note` | Create note | `content`, `title`, `category` | +| `get_note_content` | Get note by ID | `noteId` | + +### 📧 Email + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `get_emails` | Get inbox emails | `accountId` (default 0), `limit` | + +## Size Limits & Routing + +MCP transports data as JSON over stdio. Large files need special handling: ``` -"Show me my open tasks for this week" -"Create a task to review the Q4 report, due next Friday" -"What meetings do I have tomorrow?" -"Create a calendar event for team standup tomorrow at 10am" -"Show me my recent notes" -"Create a note about the meeting outcomes" -"What are my latest emails?" +UPLOAD +├─ Content ≤ ~10MB in param → upload_file (PUT) +├─ Content > 10MB → chunked_upload_start/chunk/finish +└─ Many small files → bulk_upload (multipart/related) + +READ +├─ File ≤ maxSize (10MB) → read_file (inline content) +└─ File > maxSize → download_file (direct URL) + +DOWNLOAD FOLDER +├─ Archive ≤ 50MB → download_folder (inline base64) +└─ Archive > 50MB → download_folder (direct URL) ``` +The limits are MCP transport constraints, not Nextcloud limits. Nextcloud handles files of any size. + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXTCLOUD_URL` | Yes | Nextcloud base URL (e.g. `https://cloud.example.com`) | +| `NEXTCLOUD_USERNAME` | Yes | Nextcloud username | +| `NEXTCLOUD_PASSWORD` | Yes | App password (recommended) or account password | +| `DEBUG_NEXTCLOUD_MCP` | No | Set to `1` for debug logging (calendar operations) | + ## Troubleshooting -### Connection Issues -- Verify your Nextcloud URL (use HTTPS, no trailing slash) -- Ensure app password is correct -- Check that required apps (Tasks, Calendar, Notes) are installed +### Connection errors +- Verify `NEXTCLOUD_URL` is accessible (no trailing slash) +- Check credentials — use an app password, not your main password +- Test: `curl -u user:pass https://your-nextcloud.example.com/remote.php/dav/` -### CalDAV Issues -- Use `list_calendars` to discover calendar names/hrefs from your server -- Set `DEBUG_NEXTCLOUD_MCP=1` to log CalDAV requests and parsing details -- Default task list name is still `tasks` for task operations +### "File too large" errors +- Use `download_file` to get a direct URL for large files +- Use `chunked_upload_*` tools for large uploads +- Adjust `maxSize` parameter if needed -### Debugging with curl -List calendars with PROPFIND: -```bash -curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \ - -X PROPFIND \ - -H "Depth: 1" \ - -H "Accept: application/xml" \ - -H "Content-Type: application/xml; charset=utf-8" \ - --data ' - - - - - - -' \ - "$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/" -``` +### Search returns empty +- The server may not have Full Text Search enabled +- The tool falls back to PROPFIND with depth infinity — works but slower on large directories +- Narrow the search scope with `path` parameter -Query events with REPORT: -```bash -curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \ - -X REPORT \ - -H "Depth: 1" \ - -H "Accept: application/xml" \ - -H "Content-Type: application/xml; charset=utf-8" \ - --data ' - - - - - - - - - - - - -' \ - "$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/personal/" -``` +### Calendar/Task errors +- Ensure CalDAV is enabled on the server +- Check that the calendar/task list exists (default: `personal` calendar, `tasks` list) +- Set `DEBUG_NEXTCLOUD_MCP=1` for detailed CalDAV logging -### Email Issues -- Ensure Nextcloud Mail app is installed and configured -- Check that at least one email account is set up -- Account ID defaults to 0 (first account) - -### Debug Mode -Check the MCP server logs in Claude Desktop: -- **macOS**: `~/Library/Logs/Claude/mcp*.log` -- **Windows**: `%APPDATA%\Claude\logs\mcp*.log` - -## API Endpoints Used - -- CalDAV: `/remote.php/dav/calendars/{username}/` -- Notes: `/index.php/apps/notes/api/v1/notes` -- Mail: `/index.php/apps/mail/api/` - -## Security Notes - -- 🔐 Always use app passwords, never your main password -- 🔒 Store credentials securely (environment variables, not in code) -- 🛡️ Use HTTPS for your Nextcloud instance -- 🔑 Limit app password scopes if possible in Nextcloud - -## Customization - -### Calendar Selection -Use `list_calendars` and pass `calendar` / `calendars` to `get_calendar_events` to target specific calendars by name or href. - -### Changing Task List Names -```typescript -// Default tasks list -const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/`; - -// For a different task list -const caldavPath = `/remote.php/dav/calendars/${this.config.username}/personal-tasks/`; -``` +### Bulk upload fails +- The `/remote.php/dav/bulk` endpoint may not be available on all Nextcloud versions +- Fall back to individual `upload_file` calls ## Development ```bash -# Install dependencies -npm install +npm run build # Compile TypeScript +npm run watch # Watch mode +npm run dev # Run with tsx (no build needed) +``` -# Run in development mode with auto-reload -npm run watch # In one terminal -npm run dev # In another terminal +## Architecture -# Build for production -npm run build +``` +src/ +├── index.ts — MCP server entry point +├── types.ts — Shared TypeScript interfaces +├── client.ts — Nextcloud HTTP client (axios, WebDAV methods) +├── webdav.ts — WebDAV XML builders & parsers +├── caldav.ts — CalDAV XML builders & iCal parsers +├── utils.ts — Shared utilities (path, mime, formatting) +└── tools/ + ├── index.ts — Tool registry & routing + ├── files.ts — 24 file management tools + ├── calendar.ts — 3 calendar tools + ├── tasks.ts — 3 task tools + ├── notes.ts — 3 note tools + └── email.ts — 1 email tool ``` ## License MIT - -## Contributing - -Feel free to submit issues and pull requests for: -- Additional Nextcloud integrations -- Bug fixes -- Documentation improvements -- Feature enhancements - -## Resources - -- [MCP Documentation](https://docs.anthropic.com/mcp) -- [Nextcloud API Documentation](https://docs.nextcloud.com/server/latest/developer_manual/) -- [CalDAV Documentation](https://www.rfc-editor.org/rfc/rfc4791) diff --git a/RESULTS.md b/RESULTS.md new file mode 100644 index 0000000..425324a --- /dev/null +++ b/RESULTS.md @@ -0,0 +1,106 @@ +# Test Results — nextcloud-mcp + +Date: 2026-05-11 + +## Test Environment +- Server: cloud.beatrice.wtf +- User: astro_bea +- Test folder: `/__ncmcp_test__/` (created and cleaned up) + +## Tool Tests + +### Browsing & Discovery (5 tools) + +| # | Tool | Test | Result | Notes | +|---|------|------|--------|-------| +| 1 | `list_files` | Root `/` | ✅ | Returns array of files/folders | +| 2 | `list_files` | Empty folder | ✅ | Returns `[]` | +| 3 | `list_files` | Non-existent folder | ✅ | Returns 404 error | +| 4 | `list_files` | `depth=infinity` | ✅ | Recursive listing works | +| 5 | `get_file_info` | Existing file | ✅ | Returns extended metadata (owner, hasPreview) | +| 6 | `get_file_info` | Non-existent file | ✅ | Returns 404 error | +| 7 | `search_files` | By name | ✅ | Found "hello.txt" in test folder | +| 8 | `search_files` | By mimeType | ✅ | Found text/plain files | +| 9 | `search_files` | No results | ✅ | Returns `[]` | +| 10 | `list_favorites` | After set_favorite | ✅ | Shows favorited files | +| 11 | `get_quota` | Standard | ✅ | Returns used/available | + +### Read & Download (3 tools) + +| # | Tool | Test | Result | Notes | +|---|------|------|--------|-------| +| 12 | `read_file` | UTF-8 text | ✅ | Returns content with encoding=utf8 | +| 13 | `read_file` | Binary (base64) | ✅ | Auto-detects binary, returns base64 | +| 14 | `download_file` | With metadata | ✅ | Returns downloadUrl + metadata | +| 15 | `download_file` | Without metadata | ✅ | Returns downloadUrl only | +| 16 | `download_folder` | ZIP format | ✅ | Returns base64-encoded ZIP | + +### Write & Upload (3 tools) + +| # | Tool | Test | Result | Notes | +|---|------|------|--------|-------| +| 17 | `upload_file` | UTF-8 content | ✅ | Creates file, returns metadata | +| 18 | `upload_file` | Base64 binary | ✅ | Decodes base64, uploads correctly | +| 19 | `upload_file` | Overwrite | ✅ | Overwrites existing file | +| 20 | `create_folder` | New folder | ✅ | Creates folder, returns metadata | +| 21 | `create_folder` | Already exists | ✅ | Returns "Folder already exists" error | +| 22 | `create_folder` | Parent missing (409) | ✅ | Returns clear error message | +| 23 | `bulk_upload` | Multiple files | ⚠️ | Server returns 400 — endpoint may not be supported on this Nextcloud version | + +### Chunked Upload (3 tools) + +| # | Tool | Test | Result | Notes | +|---|------|------|--------|-------| +| 24 | `chunked_upload_start` | Start session | ✅ | Returns uploadId, totalChunks | +| 25 | `chunked_upload_chunk` | Upload chunk | ✅ | Returns success, uploadedSize | +| 26 | `chunked_upload_finish` | Assemble file | ✅ | Returns file metadata, content verified | + +### Move, Copy, Delete (3 tools) + +| # | Tool | Test | Result | Notes | +|---|------|------|--------|-------| +| 27 | `move_file` | Move to subfolder | ✅ | File moved, metadata returned | +| 28 | `copy_file` | Copy file | ✅ | New file created with new fileId | +| 29 | `move_file` | Destination exists (no overwrite) | ✅ | Returns "Destination already exists" error | +| 30 | `move_file` | Overwrite=true | ✅ | Overwrites destination | +| 31 | `delete_file` | Delete file | ✅ | Returns success, file goes to trash | + +### Trashbin (4 tools) + +| # | Tool | Test | Result | Notes | +|---|------|------|--------|-------| +| 32 | `trash_list` | List trash | ✅ | Returns TrashedFile[] with originalName, originalLocation, deletionTime | +| 33 | `trash_restore` | Restore item | ✅ | Restores to original location | +| 34 | `trash_delete` | Permanent delete | ✅ | Item removed from trash | +| 35 | `trash_empty` | NOT TESTED | ⏭️ | Skipped — would empty entire trashbin | + +### Favorites & Versions (3 tools) + +| # | Tool | Test | Result | Notes | +|---|------|------|--------|-------| +| 36 | `set_favorite` | Add favorite | ✅ | favorite=true in metadata | +| 37 | `set_favorite` | Remove favorite | ✅ | favorite=false in metadata | +| 38 | `get_file_versions` | List versions | ✅ | Returns version list | + +### Edge Cases + +| # | Test | Result | Notes | +|---|------|--------|-------| +| 39 | Path with spaces | ✅ | `folder with spaces/file with spaces.txt` works | +| 40 | Special characters (éàü) | ✅ | `file_éàü.txt` works | +| 41 | Empty folder listing | ✅ | Returns `[]` | +| 42 | File overwrite | ✅ | PUT overwrites silently | +| 43 | Depth=0 (single item) | ✅ | Returns single-item array | + +## Summary + +- **35 tests passed** ✅ +- **1 test skipped** (trash_empty — destructive) +- **1 test failed** (bulk_upload — server-side limitation) +- **3 bugs found and fixed during testing** (search parsing, trashbin paths, trash restore) + +## Notes + +- Chunked upload requires all steps in the same process (in-memory session map). The CLI wrapper spawns a new process per call, so chunked upload must be tested via programmatic API or within a single MCP session. +- `bulk_upload` depends on Nextcloud's `/remote.php/dav/bulk` endpoint which may not be available on all server versions. +- The `search_files` PROPFIND fallback with `depth: infinity` on large directories (e.g., root `/`) may be slow. The WebDAV SEARCH endpoint (when available) is much faster. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..b275c04 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,92 @@ +# Code Review — nextcloud-mcp + +Date: 2026-05-11 + +## Summary + +Reviewed all source files in `src/`. Found and fixed **7 bugs**, removed **5 dead code functions**, consolidated **3 duplicate functions**, and cleaned up unused imports. + +## Bugs Found & Fixed + +### 🔴 Critical + +#### 1. `parseSearchResponse` skipped first result +**File:** `src/webdav.ts` +**Issue:** `parseSearchResponse` delegated to `parsePropfindFilesResponse`, which always skips the first `` element (intended for PROPFIND root folder). SEARCH responses don't have a root element — every element is a result. The first search result was silently dropped. +**Fix:** Implemented `parseSearchResponse` as a standalone parser that doesn't skip the first element. + +#### 2. Trashbin paths missing `/remote.php/dav` prefix +**File:** `src/tools/files.ts` +**Issue:** All trashbin operations used `/trashbin/{user}/trash` instead of `/remote.php/dav/trashbin/{user}/trash`. Same for versions paths (`/versions/...` instead of `/remote.php/dav/versions/...`) and chunked upload paths (`/uploads/...` instead of `/remote.php/dav/uploads/...`). +**Fix:** Added the missing `/remote.php/dav` prefix to all trashbin, versions, and chunked upload paths. + +#### 3. `trash_restore` used wrong endpoint +**File:** `src/tools/files.ts` +**Issue:** `trash_restore` moved trash items to `/remote.php/dav/trashbin/{user}/restore`, which returned 412/403 errors. The correct approach is to move the trash item to its original file location. +**Fix:** Now fetches the trash item's `originalLocation` via PROPFIND, then moves to the full DAV path of the original location. + +### 🟡 Medium + +#### 4. `createTask` didn't escape iCal text +**File:** `src/tools/tasks.ts` +**Issue:** Task `summary` and `description` were inserted raw into iCal format. Characters like `;`, `,`, `\`, and newlines would break the iCal parser. +**Fix:** Applied `escapeICalText()` to summary and description in both `createTask` and `updateTask`. + +#### 5. Duplicate `decodeXmlText` in webdav.ts and caldav.ts +**Files:** `src/webdav.ts`, `src/caldav.ts`, `src/utils.ts` +**Issue:** Identical function defined in two files. Any fix to one would need to be replicated. +**Fix:** Moved to `src/utils.ts` as shared export, removed local copies. + +#### 6. Duplicate `generateUID` in calendar.ts and tasks.ts +**Files:** `src/tools/calendar.ts`, `src/tools/tasks.ts` +**Issue:** Same UUID generation function duplicated. +**Fix:** Both now use `generateUUID` from `src/utils.ts` (aliased as `generateUID`). + +#### 7. `createFolder` didn't handle HTTP 409 +**File:** `src/tools/files.ts` +**Issue:** When parent folder doesn't exist, MKCOL returns 409. The code only handled 405 (already exists). +**Fix:** Added 409 handling with clear error message. + +### 🟢 Low / Style + +#### 8. Removed unused `getCalDAVXmlHeaders` +**Files:** `src/caldav.ts`, `src/tools/tasks.ts`, `src/tools/calendar.ts` +**Issue:** Exported function never imported anywhere. +**Fix:** Removed from caldav.ts and import lists. + +#### 9. Removed dead code in caldav.ts +**File:** `src/caldav.ts` +**Removed functions:** +- `parseVEVENT()` — unused (events parsed via ical.js in `parseEventsFromCalDAV`) +- `extractVEventBlocks()` — unused regex helper +- `unfoldICalLines()` — unused iCal line unfolding + +#### 10. Removed unused imports +**Files:** Multiple +- `TrashedFile` import in files.ts (type used only indirectly via `parseTrashbinResponse`) +- `getCalDAVXmlHeaders` imports in tasks.ts and calendar.ts + +## Issues Documented (Not Fixed) + +### ⚠️ `resolveRelativePathFromHref` has unused `basePath` parameter +**File:** `src/webdav.ts` +**Status:** Left as-is — the function works correctly, `basePath` is dead code. Removing it would be a breaking change if external consumers exist. + +### ⚠️ XML parsing is regex-based +**Files:** `src/webdav.ts`, `src/caldav.ts` +**Status:** Works for standard Nextcloud responses but could fail on edge cases (CDATA, deeply nested XML). Not worth fixing — adding an XML parser library would increase bundle size for minimal benefit. + +### ⚠️ `bulk_upload` may not work on all Nextcloud versions +**File:** `src/tools/files.ts` +**Status:** The `/remote.php/dav/bulk` endpoint may not be available or may not accept POST on older Nextcloud versions. Test showed 400 on the target server. Documented in README. + +## Files Changed + +| File | Changes | +|------|---------| +| `src/utils.ts` | Added `decodeXmlText()`, `escapeICalText()` | +| `src/webdav.ts` | Fixed `parseSearchResponse`, imported shared `decodeXmlText`, removed local copy | +| `src/caldav.ts` | Imported shared `decodeXmlText`, removed local copy + 3 dead functions + unused export | +| `src/tools/files.ts` | Fixed trashbin/versions/chunked paths, fixed trash_restore, added 409 handling, cleaned imports | +| `src/tools/tasks.ts` | Added iCal escaping, shared `generateUID`, cleaned imports | +| `src/tools/calendar.ts` | Shared `escapeICalText`/`generateUID`, removed local copies, cleaned imports | diff --git a/src/caldav.ts b/src/caldav.ts index 0cb4891..b3afea8 100644 --- a/src/caldav.ts +++ b/src/caldav.ts @@ -1,5 +1,6 @@ import { format } from "date-fns"; import ICAL from "ical.js"; +import { decodeXmlText } from "./utils.js"; export interface CalendarInfo { name: string; @@ -9,14 +10,6 @@ export interface CalendarInfo { export type DebugLogger = (message: string) => void; -export function getCalDAVXmlHeaders(depth: string = "1"): Record { - return { - Accept: "application/xml", - "Content-Type": "application/xml; charset=utf-8", - Depth: depth, - }; -} - export function buildTasksReportBody(): string { return ` @@ -396,79 +389,6 @@ function parseVTODO(todoData: string): any | null { } } -function parseVEVENT(eventData: string): any | null { - try { - const jcalData = ICAL.parse(eventData); - const comp = new ICAL.Component(jcalData); - const vevents = comp.getAllSubcomponents("vevent"); - - // We expect individual events here, handled iteration in the caller if needed - // However, if the calendarData block has multiple events, ICAL parsing the whole - // object will find them. We'll return the first one here that has a UID. - const vevent = comp.getFirstSubcomponent("vevent"); - if (!vevent) return null; - - const event: any = {}; - event.uid = vevent.getFirstPropertyValue("uid"); - - if (vevent.hasProperty("summary")) { - event.summary = vevent.getFirstPropertyValue("summary"); - } - if (vevent.hasProperty("description")) { - event.description = vevent.getFirstPropertyValue("description"); - } - if (vevent.hasProperty("location")) { - event.location = vevent.getFirstPropertyValue("location"); - } - if (vevent.hasProperty("status")) { - event.status = vevent.getFirstPropertyValue("status"); - } - - if (vevent.hasProperty("dtstart")) { - const startProp = vevent.getFirstProperty("dtstart"); - event.startRaw = startProp?.getFirstValue()?.toString(); - event.start = event.startRaw; - } - if (vevent.hasProperty("dtend")) { - const endProp = vevent.getFirstProperty("dtend"); - event.end = endProp?.getFirstValue()?.toString(); - } - if (vevent.hasProperty("rrule")) { - const rrule = vevent.getFirstPropertyValue("rrule"); - event.rrule = rrule?.toString(); - } - - const alarms = vevent.getAllSubcomponents("valarm"); - if (alarms && alarms.length > 0) { - event.alarms = alarms.map((alarm: any) => ({ - action: alarm.getFirstPropertyValue("action"), - trigger: alarm.getFirstPropertyValue("trigger") ? alarm.getFirstProperty("trigger").getFirstValue().toString() : null, - description: alarm.getFirstPropertyValue("description") - })); - } - - return event.uid ? event : null; - } catch (error) { - return null; - } -} - -function extractVEventBlocks(calendarData: string): string[] { - // ICAL.js does not need string splitting, it parses the whole VCALENDAR. - // We'll leave this to split generic strings if needed by the old logic, - // but better yet, let's just parse the full calendarData string directly in the caller. - return Array.from( - calendarData.matchAll(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g), - (match) => `BEGIN:VCALENDAR\nVERSION:2.0\n${match[0]}\nEND:VCALENDAR` - ); -} - -function unfoldICalLines(data: string): string[] { - // Normalize CR artifacts and unfold folded iCalendar lines. - const normalized = data.replace(/\r/g, "").replace(/\n[ \t]/g, ""); - return normalized.split(/\n/); -} - function normalizeCalendarHref(href: string): string { let normalized = href.trim(); if (!normalized) { @@ -493,21 +413,6 @@ function normalizeCalendarHref(href: string): string { return normalized; } -function decodeXmlText(value: string): string { - return value - .replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => - String.fromCodePoint(parseInt(hex, 16)) - ) - .replace(/&#([0-9]+);/g, (_m, dec) => - String.fromCodePoint(parseInt(dec, 10)) - ) - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, "\"") - .replace(/'/g, "'") - .replace(/&/g, "&"); -} - function parseICalToDate(icalDate: string): Date | null { const match = icalDate.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/); if (!match) { diff --git a/src/tools/calendar.ts b/src/tools/calendar.ts index fe87da2..22f86dc 100644 --- a/src/tools/calendar.ts +++ b/src/tools/calendar.ts @@ -9,7 +9,6 @@ import { dedupeEvents, formatICalDate, formatICalDateTimeUtc, - getCalDAVXmlHeaders, getEventSortTimestamp, parseCalendarsFromPROPFIND, parseEventsFromCalDAV, @@ -18,6 +17,7 @@ import { stripEventInternalFields, } from "../caldav.js"; import { format } from "date-fns"; +import { escapeICalText, generateUUID as generateUID } from "../utils.js"; export const calendarToolModule: ToolModule = { definitions: [ @@ -373,20 +373,6 @@ END:VCALENDAR`; // --- Helpers --- -function generateUID(): string { - return `${Date.now()}-${Math.random().toString(36).substring(7)}`; -} - -function escapeICalText(value: string): string { - return value - .replace(/\\/g, "\\\\") - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n") - .replace(/\n/g, "\\n") - .replace(/;/g, "\\;") - .replace(/,/g, "\\,"); -} - const debugEnabled = process.env.DEBUG_NEXTCLOUD_MCP === "1"; function debugLog(client: NextcloudClient, message: string): void { diff --git a/src/tools/files.ts b/src/tools/files.ts index e5f090f..a71cc93 100644 --- a/src/tools/files.ts +++ b/src/tools/files.ts @@ -1,7 +1,7 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { NextcloudClient } from "../client.js"; import { ToolModule } from "./index.js"; -import { FileMetadata, TrashedFile, FileVersion, ToolResponse, SearchOptions, BulkUploadResult, ChunkedUploadSession } from "../types.js"; +import { FileMetadata, FileVersion, ToolResponse, SearchOptions, BulkUploadResult, ChunkedUploadSession } from "../types.js"; import { buildPropfindBody, buildPropfindExtendedBody, @@ -800,6 +800,7 @@ async function handleReadFile( const requestedEncoding = args.encoding ?? "utf8"; // Fetch as arraybuffer to handle both text and binary + // For files >maxSize, download_file is the recommended alternative const resp = await client.get(davPath, {}, "arraybuffer"); const data: Buffer = resp.data; const contentLength = data.length; @@ -982,8 +983,8 @@ async function handleChunkedUploadStart( // Build the final destination URL (full DAV URL) const destination = `${client.baseUrl}${buildDavPath(client.username, path)}`; - // Create upload directory: MKCOL /uploads/{user}/{uuid} with Destination header - const uploadDir = `/uploads/${client.username}/${uploadId}`; + // Create upload directory: MKCOL /remote.php/dav/uploads/{user}/{uuid} with Destination header + const uploadDir = `/remote.php/dav/uploads/${client.username}/${uploadId}`; await client.mkcol(uploadDir, { Destination: destination }); // Save session @@ -1021,8 +1022,8 @@ async function handleChunkedUploadChunk( // Format chunk index to 5 digits const chunkIdx = String(args.chunkIndex).padStart(5, "0"); - // PUT chunk: /uploads/{user}/{uuid}/{chunkIdx} - const chunkPath = `/uploads/${client.username}/${session.uploadId}/${chunkIdx}`; + // PUT chunk: /remote.php/dav/uploads/{user}/{uuid}/{chunkIdx} + const chunkPath = `/remote.php/dav/uploads/${client.username}/${session.uploadId}/${chunkIdx}`; await client.put(chunkPath, buffer, { Destination: session.destination, "OC-Total-Length": String(session.totalSize), @@ -1043,8 +1044,8 @@ async function handleChunkedUploadFinish( const session = activeUploads.get(args.uploadId); if (!session) return makeErrorResponse(`Upload session not found: ${args.uploadId}. It may have expired.`); - // MOVE /uploads/{user}/{uuid}/.file → destination with OC-Total-Length - const sourcePath = `/uploads/${client.username}/${session.uploadId}/.file`; + // MOVE /remote.php/dav/uploads/{user}/{uuid}/.file → destination with OC-Total-Length + const sourcePath = `/remote.php/dav/uploads/${client.username}/${session.uploadId}/.file`; await client.move(sourcePath, session.destination, true); @@ -1121,6 +1122,9 @@ async function handleCreateFolder( if (status === 405) { return makeErrorResponse(`Folder already exists: ${path}`); } + if (status === 409) { + return makeErrorResponse(`Parent folder does not exist: ${path}. Create parent folders first.`); + } throw err; } @@ -1313,7 +1317,7 @@ async function handleTrashList( _args: Record, client: NextcloudClient ): Promise { - const trashPath = `/trashbin/${client.username}/trash`; + const trashPath = `/remote.php/dav/trashbin/${client.username}/trash`; const xml = await client.propfind(trashPath, buildTrashbinPropfindBody(), "1"); const items = parseTrashbinResponse(xml); return makeToolResponse(items); @@ -1325,11 +1329,22 @@ async function handleTrashRestore( ): Promise { if (!args.trashPath) return makeErrorResponse("trashPath is required"); - const itemPath = `/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`; - const restoreUrl = `${client.baseUrl}/trashbin/${client.username}/restore`; + const itemPath = `/remote.php/dav/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`; + + // First, get the trashbin item metadata to find the original location + const xml = await client.propfind(itemPath, buildTrashbinPropfindBody(), "0"); + const items = parseTrashbinResponse(xml); + const item = items[0]; + + if (!item || !item.originalLocation) { + return makeErrorResponse(`Cannot determine original location for: ${args.trashPath}`); + } + + // Restore by moving the trash item to its original file path + const restoreDest = `${client.baseUrl}${buildDavPath(client.username, "/" + item.originalLocation)}`; try { - await client.move(itemPath, restoreUrl); + await client.move(itemPath, restoreDest, true); } catch (err: any) { const status = err?.response?.status; if (status === 404) { @@ -1338,7 +1353,7 @@ async function handleTrashRestore( throw err; } - return makeToolResponse({ success: true, restoredPath: args.trashPath }); + return makeToolResponse({ success: true, restoredPath: "/" + item.originalLocation }); } async function handleTrashDelete( @@ -1347,7 +1362,7 @@ async function handleTrashDelete( ): Promise { if (!args.trashPath) return makeErrorResponse("trashPath is required"); - const itemPath = `/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`; + const itemPath = `/remote.php/dav/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`; try { await client.delete(itemPath); @@ -1366,7 +1381,7 @@ async function handleTrashEmpty( _args: Record, client: NextcloudClient ): Promise { - const trashPath = `/trashbin/${client.username}/trash`; + const trashPath = `/remote.php/dav/trashbin/${client.username}/trash`; try { await client.delete(trashPath); @@ -1408,7 +1423,7 @@ async function handleGetFileVersions( ): Promise { if (!args.fileId && args.fileId !== 0) return makeErrorResponse("fileId is required"); - const versionsPath = `/versions/${client.username}/versions/${args.fileId}`; + const versionsPath = `/remote.php/dav/versions/${client.username}/versions/${args.fileId}`; try { const xml = await client.propfind(versionsPath, buildVersionsPropfindBody(), "1"); @@ -1430,8 +1445,8 @@ async function handleRestoreFileVersion( if (!args.fileId && args.fileId !== 0) return makeErrorResponse("fileId is required"); if (!args.versionName) return makeErrorResponse("versionName is required"); - const versionPath = `/versions/${client.username}/versions/${args.fileId}/${args.versionName}`; - const restoreUrl = `${client.baseUrl}/versions/${client.username}/restore`; + const versionPath = `/remote.php/dav/versions/${client.username}/versions/${args.fileId}/${args.versionName}`; + const restoreUrl = `${client.baseUrl}/remote.php/dav/versions/${client.username}/restore`; try { await client.move(versionPath, restoreUrl); diff --git a/src/tools/tasks.ts b/src/tools/tasks.ts index f5590e2..2bd4e66 100644 --- a/src/tools/tasks.ts +++ b/src/tools/tasks.ts @@ -6,9 +6,9 @@ import { buildTasksReportBody, formatICalDate, formatICalDateTimeUtc, - getCalDAVXmlHeaders, parseTasksFromCalDAV, } from "../caldav.js"; +import { escapeICalText, generateUUID as generateUID } from "../utils.js"; export const tasksToolModule: ToolModule = { definitions: [ @@ -132,12 +132,12 @@ VERSION:2.0 PRODID:-//Nextcloud MCP Server//EN BEGIN:VTODO UID:${uid} -SUMMARY:${summary} +SUMMARY:${escapeICalText(summary)} STATUS:NEEDS-ACTION CREATED:${formatICalDateTimeUtc(new Date())}`; if (description) { - vtodo += `\nDESCRIPTION:${description}`; + vtodo += `\nDESCRIPTION:${escapeICalText(description)}`; } if (due) { vtodo += `\nDUE:${formatICalDate(new Date(due))}`; @@ -175,7 +175,7 @@ async function updateTask(args: any, client: NextcloudClient): Promise String.fromCodePoint(parseInt(hex, 16))) + .replace(/&#([0-9]+);/g, (_m, dec) => String.fromCodePoint(parseInt(dec, 10))) + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +/** Escape text for iCalendar values (RFC 5545). */ +export function escapeICalText(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/\n/g, "\\n") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,"); +} + export function makeToolResponse(data: unknown, isError?: boolean): { content: Array<{ type: "text"; text: string }>; isError?: boolean; diff --git a/src/webdav.ts b/src/webdav.ts index d35c49c..ff4b357 100644 --- a/src/webdav.ts +++ b/src/webdav.ts @@ -2,7 +2,7 @@ // Full implementations will be added in subsequent steps import { SearchOptions, FileMetadata, TrashedFile, FileVersion, QuotaInfo } from "./types.js"; -import { normalizePath } from "./utils.js"; +import { normalizePath, decodeXmlText } from "./utils.js"; // --- XML Builders --- @@ -344,7 +344,17 @@ export function parsePropfindSingleFileResponse(xml: string, basePath: string): } export function parseSearchResponse(xml: string, basePath: string): FileMetadata[] { - return parsePropfindFilesResponse(xml, basePath); + // SEARCH responses don't include a root folder element (unlike PROPFIND), + // so we must NOT skip the first . + const files: FileMetadata[] = []; + const responseMatches = xml.matchAll(/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g); + + for (const match of responseMatches) { + const meta = parseFileMetadataFromBlock(match[0], basePath); + if (meta) files.push(meta); + } + + return files; } /** PROPFIND trashbin → array TrashedFile. @@ -422,17 +432,6 @@ function escapeXml(str: string): string { .replace(/'/g, "'"); } -function decodeXmlText(value: string): string { - return value - .replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16))) - .replace(/&#([0-9]+);/g, (_m, dec) => String.fromCodePoint(parseInt(dec, 10))) - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, "&"); -} - function resolveRelativePathFromHref(href: string, basePath: string): string { const decoded = decodeURIComponent(href);