Step 10: Code review, testing & documentation
Code Review (REVIEW.md): - Fixed parseSearchResponse skipping first result (critical bug) - Fixed trashbin/versions/chunked paths missing /remote.php/dav prefix - Fixed trash_restore to use original file location instead of /restore endpoint - Fixed createTask/updateTask missing iCal text escaping - Added 409 handling for createFolder (parent missing) - Extracted duplicate decodeXmlText to utils.ts - Extracted duplicate generateUID to utils.ts (shared with calendar/tasks) - Removed 5 dead code functions (parseVEVENT, extractVEventBlocks, unfoldICalLines, getCalDAVXmlHeaders, local decodeXmlText) - Cleaned unused imports across all tool files Testing (RESULTS.md): - 35 tests passed, 1 skipped (trash_empty), 1 server limitation (bulk_upload) - Tested all 21+ file tools, edge cases (spaces, unicode, overwrite, empty folders) - Verified chunked upload end-to-end Documentation (README.md): - Complete tool reference (21 file + 10 other tools) - Quick start, CLI usage, size limits, troubleshooting - Architecture overview
This commit is contained in:
@@ -1,285 +1,273 @@
|
|||||||
# Nextcloud MCP Server
|
# nextcloud-mcp
|
||||||
|
|
||||||
A Model Context Protocol (MCP) server that integrates with Nextcloud to provide access to:
|
MCP (Model Context Protocol) server for Nextcloud — browse, read, write, and manage files, calendars, tasks, notes, and email via WebDAV/CalDAV/REST APIs.
|
||||||
- **Tasks** (via CalDAV)
|
|
||||||
- **Calendar Events** (via CalDAV)
|
|
||||||
- **Notes** (via Notes API)
|
|
||||||
- **Emails** (via Mail API)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Tasks
|
- **21 file management tools** — browse, search, read, upload, move, copy, delete, trashbin, favorites, versions
|
||||||
- ✅ Get tasks (filter by status: all/open/completed)
|
- **Calendar tools** — list calendars, get/create events (CalDAV)
|
||||||
- ✅ Create new tasks with due dates and priorities
|
- **Task tools** — get/create/update tasks (CalDAV VTODO)
|
||||||
- ✅ Update tasks (mark complete, change summary, etc.)
|
- **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
|
## Quick Start
|
||||||
- ✅ 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
|
|
||||||
|
|
||||||
### Notes
|
### 1. Configure
|
||||||
- ✅ Get all notes
|
|
||||||
- ✅ Create new notes with markdown support
|
|
||||||
- ✅ Get specific note content by ID
|
|
||||||
|
|
||||||
### Email
|
Create `.env` in the project root:
|
||||||
- ✅ Get emails from inbox
|
|
||||||
- 📧 Requires Nextcloud Mail app configured
|
|
||||||
|
|
||||||
## 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)
|
> Use an **app password** (Settings → Security → App passwords) instead of your main password.
|
||||||
2. **Required Nextcloud Apps**:
|
|
||||||
- Tasks (for task management)
|
|
||||||
- Calendar (for events)
|
|
||||||
- Notes (for note-taking)
|
|
||||||
- Mail (optional, for email access)
|
|
||||||
|
|
||||||
3. **App Password**: Generate in Nextcloud Settings > Security > Devices & sessions
|
### 2. Build
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
### 3. Test Connection
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development mode (auto-reload)
|
node ncmcp.mjs list_files path=/
|
||||||
npm run dev
|
node ncmcp.mjs get_quota
|
||||||
|
|
||||||
# Production mode
|
|
||||||
npm run start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using with Claude Desktop
|
### 4. Use with MCP (mcporter)
|
||||||
|
|
||||||
Add this to your Claude Desktop configuration file:
|
Add to your `mcporter.json`:
|
||||||
|
|
||||||
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
||||||
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"nextcloud": {
|
||||||
"nextcloud": {
|
"command": "node",
|
||||||
"command": "node",
|
"args": ["path/to/nextcloud-mcp/build/index.js"],
|
||||||
"args": ["/path/to/nextcloud-mcp/build/index.js"],
|
"env": {
|
||||||
"env": {
|
"NEXTCLOUD_URL": "https://your-nextcloud.example.com",
|
||||||
"NEXTCLOUD_URL": "https://your-nextcloud.com",
|
"NEXTCLOUD_USERNAME": "your_username",
|
||||||
"NEXTCLOUD_USERNAME": "your-username",
|
"NEXTCLOUD_PASSWORD": "your_app_password"
|
||||||
"NEXTCLOUD_PASSWORD": "your-app-password"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the development version with tsx:
|
Then use: `mcporter call nextcloud.list_files --args '{"path":"/"}'`
|
||||||
|
|
||||||
```json
|
## CLI Usage (ncmcp)
|
||||||
{
|
|
||||||
"mcpServers": {
|
```bash
|
||||||
"nextcloud": {
|
ncmcp <tool> [key=value ...] [positionalPath] [@file] [--curl]
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
# Upload a file
|
||||||
- `get_tasks` - Retrieve tasks (filter by status, limit results)
|
ncmcp upload_file path=/Documents/new.txt content="Hello World"
|
||||||
- `create_task` - Create new task with summary, description, due date, priority
|
ncmcp upload_file path=/Documents/photo.jpg @./local-photo.jpg
|
||||||
- `update_task` - Update existing task (mark complete, change details)
|
|
||||||
|
|
||||||
### Calendar
|
# Download (get URL)
|
||||||
- `list_calendars` - List available calendars (`name`, `href`, `components`)
|
ncmcp download_file path=/Documents/video.mp4
|
||||||
- `get_calendar_events` - Get events in date range across selected calendars
|
ncmcp download_file path=/Documents/video.mp4 --curl # prints curl command
|
||||||
- `create_calendar_event` - Create new event with details
|
|
||||||
|
|
||||||
`get_calendar_events` supports:
|
# Search
|
||||||
- `startDate` / `endDate` (YYYY-MM-DD)
|
ncmcp search_files query="report" mimeType="application/pdf" limit=10
|
||||||
- `calendar` (single calendar name or href)
|
|
||||||
- `calendars` (array of names/hrefs)
|
|
||||||
- `includeAllCalendars` (default `true`, queries all VEVENT calendars when no selectors are provided)
|
|
||||||
- `limit`
|
|
||||||
|
|
||||||
### Notes
|
# Pipe content
|
||||||
- `get_notes` - List all notes
|
echo "file contents" | ncmcp upload_file path=/test.txt
|
||||||
- `create_note` - Create new note with markdown
|
```
|
||||||
- `get_note_content` - Get full content of specific note
|
|
||||||
|
|
||||||
### Email
|
## Tool Reference
|
||||||
- `get_emails` - Retrieve recent emails from inbox
|
|
||||||
|
|
||||||
## 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"
|
UPLOAD
|
||||||
"Create a task to review the Q4 report, due next Friday"
|
├─ Content ≤ ~10MB in param → upload_file (PUT)
|
||||||
"What meetings do I have tomorrow?"
|
├─ Content > 10MB → chunked_upload_start/chunk/finish
|
||||||
"Create a calendar event for team standup tomorrow at 10am"
|
└─ Many small files → bulk_upload (multipart/related)
|
||||||
"Show me my recent notes"
|
|
||||||
"Create a note about the meeting outcomes"
|
READ
|
||||||
"What are my latest emails?"
|
├─ 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
|
## Troubleshooting
|
||||||
|
|
||||||
### Connection Issues
|
### Connection errors
|
||||||
- Verify your Nextcloud URL (use HTTPS, no trailing slash)
|
- Verify `NEXTCLOUD_URL` is accessible (no trailing slash)
|
||||||
- Ensure app password is correct
|
- Check credentials — use an app password, not your main password
|
||||||
- Check that required apps (Tasks, Calendar, Notes) are installed
|
- Test: `curl -u user:pass https://your-nextcloud.example.com/remote.php/dav/`
|
||||||
|
|
||||||
### CalDAV Issues
|
### "File too large" errors
|
||||||
- Use `list_calendars` to discover calendar names/hrefs from your server
|
- Use `download_file` to get a direct URL for large files
|
||||||
- Set `DEBUG_NEXTCLOUD_MCP=1` to log CalDAV requests and parsing details
|
- Use `chunked_upload_*` tools for large uploads
|
||||||
- Default task list name is still `tasks` for task operations
|
- Adjust `maxSize` parameter if needed
|
||||||
|
|
||||||
### Debugging with curl
|
### Search returns empty
|
||||||
List calendars with PROPFIND:
|
- The server may not have Full Text Search enabled
|
||||||
```bash
|
- The tool falls back to PROPFIND with depth infinity — works but slower on large directories
|
||||||
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
- Narrow the search scope with `path` parameter
|
||||||
-X PROPFIND \
|
|
||||||
-H "Depth: 1" \
|
|
||||||
-H "Accept: application/xml" \
|
|
||||||
-H "Content-Type: application/xml; charset=utf-8" \
|
|
||||||
--data '<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<d:prop>
|
|
||||||
<d:displayname />
|
|
||||||
<d:resourcetype />
|
|
||||||
<c:supported-calendar-component-set />
|
|
||||||
</d:prop>
|
|
||||||
</d:propfind>' \
|
|
||||||
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/"
|
|
||||||
```
|
|
||||||
|
|
||||||
Query events with REPORT:
|
### Calendar/Task errors
|
||||||
```bash
|
- Ensure CalDAV is enabled on the server
|
||||||
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
- Check that the calendar/task list exists (default: `personal` calendar, `tasks` list)
|
||||||
-X REPORT \
|
- Set `DEBUG_NEXTCLOUD_MCP=1` for detailed CalDAV logging
|
||||||
-H "Depth: 1" \
|
|
||||||
-H "Accept: application/xml" \
|
|
||||||
-H "Content-Type: application/xml; charset=utf-8" \
|
|
||||||
--data '<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
|
|
||||||
<d:prop>
|
|
||||||
<d:getetag />
|
|
||||||
<c:calendar-data />
|
|
||||||
</d:prop>
|
|
||||||
<c:filter>
|
|
||||||
<c:comp-filter name="VCALENDAR">
|
|
||||||
<c:comp-filter name="VEVENT">
|
|
||||||
<c:time-range start="20260201T000000Z" end="20260301T000000Z"/>
|
|
||||||
</c:comp-filter>
|
|
||||||
</c:comp-filter>
|
|
||||||
</c:filter>
|
|
||||||
</c:calendar-query>' \
|
|
||||||
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/personal/"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Email Issues
|
### Bulk upload fails
|
||||||
- Ensure Nextcloud Mail app is installed and configured
|
- The `/remote.php/dav/bulk` endpoint may not be available on all Nextcloud versions
|
||||||
- Check that at least one email account is set up
|
- Fall back to individual `upload_file` calls
|
||||||
- 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/`;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
npm run build # Compile TypeScript
|
||||||
npm install
|
npm run watch # Watch mode
|
||||||
|
npm run dev # Run with tsx (no build needed)
|
||||||
|
```
|
||||||
|
|
||||||
# Run in development mode with auto-reload
|
## Architecture
|
||||||
npm run watch # In one terminal
|
|
||||||
npm run dev # In another terminal
|
|
||||||
|
|
||||||
# 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
|
## License
|
||||||
|
|
||||||
MIT
|
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)
|
|
||||||
|
|||||||
+106
@@ -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.
|
||||||
@@ -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 `<response>` 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 |
|
||||||
+1
-96
@@ -1,5 +1,6 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import ICAL from "ical.js";
|
import ICAL from "ical.js";
|
||||||
|
import { decodeXmlText } from "./utils.js";
|
||||||
|
|
||||||
export interface CalendarInfo {
|
export interface CalendarInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -9,14 +10,6 @@ export interface CalendarInfo {
|
|||||||
|
|
||||||
export type DebugLogger = (message: string) => void;
|
export type DebugLogger = (message: string) => void;
|
||||||
|
|
||||||
export function getCalDAVXmlHeaders(depth: string = "1"): Record<string, string> {
|
|
||||||
return {
|
|
||||||
Accept: "application/xml",
|
|
||||||
"Content-Type": "application/xml; charset=utf-8",
|
|
||||||
Depth: depth,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildTasksReportBody(): string {
|
export function buildTasksReportBody(): string {
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
|
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
|
||||||
@@ -396,79 +389,6 @@ function parseVTODO(todoData: string): any | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseVEVENT(eventData: string): any | null {
|
|
||||||
try {
|
|
||||||
const jcalData = ICAL.parse(eventData);
|
|
||||||
const comp = new ICAL.Component(jcalData);
|
|
||||||
const vevents = comp.getAllSubcomponents("vevent");
|
|
||||||
|
|
||||||
// We expect individual events here, handled iteration in the caller if needed
|
|
||||||
// However, if the calendarData block has multiple events, ICAL parsing the whole
|
|
||||||
// object will find them. We'll return the first one here that has a UID.
|
|
||||||
const vevent = comp.getFirstSubcomponent("vevent");
|
|
||||||
if (!vevent) return null;
|
|
||||||
|
|
||||||
const event: any = {};
|
|
||||||
event.uid = vevent.getFirstPropertyValue("uid");
|
|
||||||
|
|
||||||
if (vevent.hasProperty("summary")) {
|
|
||||||
event.summary = vevent.getFirstPropertyValue("summary");
|
|
||||||
}
|
|
||||||
if (vevent.hasProperty("description")) {
|
|
||||||
event.description = vevent.getFirstPropertyValue("description");
|
|
||||||
}
|
|
||||||
if (vevent.hasProperty("location")) {
|
|
||||||
event.location = vevent.getFirstPropertyValue("location");
|
|
||||||
}
|
|
||||||
if (vevent.hasProperty("status")) {
|
|
||||||
event.status = vevent.getFirstPropertyValue("status");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vevent.hasProperty("dtstart")) {
|
|
||||||
const startProp = vevent.getFirstProperty("dtstart");
|
|
||||||
event.startRaw = startProp?.getFirstValue()?.toString();
|
|
||||||
event.start = event.startRaw;
|
|
||||||
}
|
|
||||||
if (vevent.hasProperty("dtend")) {
|
|
||||||
const endProp = vevent.getFirstProperty("dtend");
|
|
||||||
event.end = endProp?.getFirstValue()?.toString();
|
|
||||||
}
|
|
||||||
if (vevent.hasProperty("rrule")) {
|
|
||||||
const rrule = vevent.getFirstPropertyValue("rrule");
|
|
||||||
event.rrule = rrule?.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const alarms = vevent.getAllSubcomponents("valarm");
|
|
||||||
if (alarms && alarms.length > 0) {
|
|
||||||
event.alarms = alarms.map((alarm: any) => ({
|
|
||||||
action: alarm.getFirstPropertyValue("action"),
|
|
||||||
trigger: alarm.getFirstPropertyValue("trigger") ? alarm.getFirstProperty("trigger").getFirstValue().toString() : null,
|
|
||||||
description: alarm.getFirstPropertyValue("description")
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return event.uid ? event : null;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractVEventBlocks(calendarData: string): string[] {
|
|
||||||
// ICAL.js does not need string splitting, it parses the whole VCALENDAR.
|
|
||||||
// We'll leave this to split generic strings if needed by the old logic,
|
|
||||||
// but better yet, let's just parse the full calendarData string directly in the caller.
|
|
||||||
return Array.from(
|
|
||||||
calendarData.matchAll(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g),
|
|
||||||
(match) => `BEGIN:VCALENDAR\nVERSION:2.0\n${match[0]}\nEND:VCALENDAR`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function unfoldICalLines(data: string): string[] {
|
|
||||||
// Normalize CR artifacts and unfold folded iCalendar lines.
|
|
||||||
const normalized = data.replace(/\r/g, "").replace(/\n[ \t]/g, "");
|
|
||||||
return normalized.split(/\n/);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCalendarHref(href: string): string {
|
function normalizeCalendarHref(href: string): string {
|
||||||
let normalized = href.trim();
|
let normalized = href.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -493,21 +413,6 @@ function normalizeCalendarHref(href: string): string {
|
|||||||
return normalized;
|
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 {
|
function parseICalToDate(icalDate: string): Date | null {
|
||||||
const match = icalDate.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/);
|
const match = icalDate.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|||||||
+1
-15
@@ -9,7 +9,6 @@ import {
|
|||||||
dedupeEvents,
|
dedupeEvents,
|
||||||
formatICalDate,
|
formatICalDate,
|
||||||
formatICalDateTimeUtc,
|
formatICalDateTimeUtc,
|
||||||
getCalDAVXmlHeaders,
|
|
||||||
getEventSortTimestamp,
|
getEventSortTimestamp,
|
||||||
parseCalendarsFromPROPFIND,
|
parseCalendarsFromPROPFIND,
|
||||||
parseEventsFromCalDAV,
|
parseEventsFromCalDAV,
|
||||||
@@ -18,6 +17,7 @@ import {
|
|||||||
stripEventInternalFields,
|
stripEventInternalFields,
|
||||||
} from "../caldav.js";
|
} from "../caldav.js";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { escapeICalText, generateUUID as generateUID } from "../utils.js";
|
||||||
|
|
||||||
export const calendarToolModule: ToolModule = {
|
export const calendarToolModule: ToolModule = {
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -373,20 +373,6 @@ END:VCALENDAR`;
|
|||||||
|
|
||||||
// --- Helpers ---
|
// --- 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";
|
const debugEnabled = process.env.DEBUG_NEXTCLOUD_MCP === "1";
|
||||||
|
|
||||||
function debugLog(client: NextcloudClient, message: string): void {
|
function debugLog(client: NextcloudClient, message: string): void {
|
||||||
|
|||||||
+32
-17
@@ -1,7 +1,7 @@
|
|||||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { NextcloudClient } from "../client.js";
|
import { NextcloudClient } from "../client.js";
|
||||||
import { ToolModule } from "./index.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 {
|
import {
|
||||||
buildPropfindBody,
|
buildPropfindBody,
|
||||||
buildPropfindExtendedBody,
|
buildPropfindExtendedBody,
|
||||||
@@ -800,6 +800,7 @@ async function handleReadFile(
|
|||||||
const requestedEncoding = args.encoding ?? "utf8";
|
const requestedEncoding = args.encoding ?? "utf8";
|
||||||
|
|
||||||
// Fetch as arraybuffer to handle both text and binary
|
// 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 resp = await client.get(davPath, {}, "arraybuffer");
|
||||||
const data: Buffer = resp.data;
|
const data: Buffer = resp.data;
|
||||||
const contentLength = data.length;
|
const contentLength = data.length;
|
||||||
@@ -982,8 +983,8 @@ async function handleChunkedUploadStart(
|
|||||||
// Build the final destination URL (full DAV URL)
|
// Build the final destination URL (full DAV URL)
|
||||||
const destination = `${client.baseUrl}${buildDavPath(client.username, path)}`;
|
const destination = `${client.baseUrl}${buildDavPath(client.username, path)}`;
|
||||||
|
|
||||||
// Create upload directory: MKCOL /uploads/{user}/{uuid} with Destination header
|
// Create upload directory: MKCOL /remote.php/dav/uploads/{user}/{uuid} with Destination header
|
||||||
const uploadDir = `/uploads/${client.username}/${uploadId}`;
|
const uploadDir = `/remote.php/dav/uploads/${client.username}/${uploadId}`;
|
||||||
await client.mkcol(uploadDir, { Destination: destination });
|
await client.mkcol(uploadDir, { Destination: destination });
|
||||||
|
|
||||||
// Save session
|
// Save session
|
||||||
@@ -1021,8 +1022,8 @@ async function handleChunkedUploadChunk(
|
|||||||
// Format chunk index to 5 digits
|
// Format chunk index to 5 digits
|
||||||
const chunkIdx = String(args.chunkIndex).padStart(5, "0");
|
const chunkIdx = String(args.chunkIndex).padStart(5, "0");
|
||||||
|
|
||||||
// PUT chunk: /uploads/{user}/{uuid}/{chunkIdx}
|
// PUT chunk: /remote.php/dav/uploads/{user}/{uuid}/{chunkIdx}
|
||||||
const chunkPath = `/uploads/${client.username}/${session.uploadId}/${chunkIdx}`;
|
const chunkPath = `/remote.php/dav/uploads/${client.username}/${session.uploadId}/${chunkIdx}`;
|
||||||
await client.put(chunkPath, buffer, {
|
await client.put(chunkPath, buffer, {
|
||||||
Destination: session.destination,
|
Destination: session.destination,
|
||||||
"OC-Total-Length": String(session.totalSize),
|
"OC-Total-Length": String(session.totalSize),
|
||||||
@@ -1043,8 +1044,8 @@ async function handleChunkedUploadFinish(
|
|||||||
const session = activeUploads.get(args.uploadId);
|
const session = activeUploads.get(args.uploadId);
|
||||||
if (!session) return makeErrorResponse(`Upload session not found: ${args.uploadId}. It may have expired.`);
|
if (!session) return makeErrorResponse(`Upload session not found: ${args.uploadId}. It may have expired.`);
|
||||||
|
|
||||||
// MOVE /uploads/{user}/{uuid}/.file → destination with OC-Total-Length
|
// MOVE /remote.php/dav/uploads/{user}/{uuid}/.file → destination with OC-Total-Length
|
||||||
const sourcePath = `/uploads/${client.username}/${session.uploadId}/.file`;
|
const sourcePath = `/remote.php/dav/uploads/${client.username}/${session.uploadId}/.file`;
|
||||||
|
|
||||||
await client.move(sourcePath, session.destination, true);
|
await client.move(sourcePath, session.destination, true);
|
||||||
|
|
||||||
@@ -1121,6 +1122,9 @@ async function handleCreateFolder(
|
|||||||
if (status === 405) {
|
if (status === 405) {
|
||||||
return makeErrorResponse(`Folder already exists: ${path}`);
|
return makeErrorResponse(`Folder already exists: ${path}`);
|
||||||
}
|
}
|
||||||
|
if (status === 409) {
|
||||||
|
return makeErrorResponse(`Parent folder does not exist: ${path}. Create parent folders first.`);
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1313,7 +1317,7 @@ async function handleTrashList(
|
|||||||
_args: Record<string, never>,
|
_args: Record<string, never>,
|
||||||
client: NextcloudClient
|
client: NextcloudClient
|
||||||
): Promise<ToolResponse> {
|
): Promise<ToolResponse> {
|
||||||
const trashPath = `/trashbin/${client.username}/trash`;
|
const trashPath = `/remote.php/dav/trashbin/${client.username}/trash`;
|
||||||
const xml = await client.propfind(trashPath, buildTrashbinPropfindBody(), "1");
|
const xml = await client.propfind(trashPath, buildTrashbinPropfindBody(), "1");
|
||||||
const items = parseTrashbinResponse(xml);
|
const items = parseTrashbinResponse(xml);
|
||||||
return makeToolResponse(items);
|
return makeToolResponse(items);
|
||||||
@@ -1325,11 +1329,22 @@ async function handleTrashRestore(
|
|||||||
): Promise<ToolResponse> {
|
): Promise<ToolResponse> {
|
||||||
if (!args.trashPath) return makeErrorResponse("trashPath is required");
|
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)}`;
|
||||||
const restoreUrl = `${client.baseUrl}/trashbin/${client.username}/restore`;
|
|
||||||
|
// 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 {
|
try {
|
||||||
await client.move(itemPath, restoreUrl);
|
await client.move(itemPath, restoreDest, true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const status = err?.response?.status;
|
const status = err?.response?.status;
|
||||||
if (status === 404) {
|
if (status === 404) {
|
||||||
@@ -1338,7 +1353,7 @@ async function handleTrashRestore(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return makeToolResponse({ success: true, restoredPath: args.trashPath });
|
return makeToolResponse({ success: true, restoredPath: "/" + item.originalLocation });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTrashDelete(
|
async function handleTrashDelete(
|
||||||
@@ -1347,7 +1362,7 @@ async function handleTrashDelete(
|
|||||||
): Promise<ToolResponse> {
|
): Promise<ToolResponse> {
|
||||||
if (!args.trashPath) return makeErrorResponse("trashPath is required");
|
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 {
|
try {
|
||||||
await client.delete(itemPath);
|
await client.delete(itemPath);
|
||||||
@@ -1366,7 +1381,7 @@ async function handleTrashEmpty(
|
|||||||
_args: Record<string, never>,
|
_args: Record<string, never>,
|
||||||
client: NextcloudClient
|
client: NextcloudClient
|
||||||
): Promise<ToolResponse> {
|
): Promise<ToolResponse> {
|
||||||
const trashPath = `/trashbin/${client.username}/trash`;
|
const trashPath = `/remote.php/dav/trashbin/${client.username}/trash`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.delete(trashPath);
|
await client.delete(trashPath);
|
||||||
@@ -1408,7 +1423,7 @@ async function handleGetFileVersions(
|
|||||||
): Promise<ToolResponse> {
|
): Promise<ToolResponse> {
|
||||||
if (!args.fileId && args.fileId !== 0) return makeErrorResponse("fileId is required");
|
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 {
|
try {
|
||||||
const xml = await client.propfind(versionsPath, buildVersionsPropfindBody(), "1");
|
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.fileId && args.fileId !== 0) return makeErrorResponse("fileId is required");
|
||||||
if (!args.versionName) return makeErrorResponse("versionName is required");
|
if (!args.versionName) return makeErrorResponse("versionName is required");
|
||||||
|
|
||||||
const versionPath = `/versions/${client.username}/versions/${args.fileId}/${args.versionName}`;
|
const versionPath = `/remote.php/dav/versions/${client.username}/versions/${args.fileId}/${args.versionName}`;
|
||||||
const restoreUrl = `${client.baseUrl}/versions/${client.username}/restore`;
|
const restoreUrl = `${client.baseUrl}/remote.php/dav/versions/${client.username}/restore`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.move(versionPath, restoreUrl);
|
await client.move(versionPath, restoreUrl);
|
||||||
|
|||||||
+4
-7
@@ -6,9 +6,9 @@ import {
|
|||||||
buildTasksReportBody,
|
buildTasksReportBody,
|
||||||
formatICalDate,
|
formatICalDate,
|
||||||
formatICalDateTimeUtc,
|
formatICalDateTimeUtc,
|
||||||
getCalDAVXmlHeaders,
|
|
||||||
parseTasksFromCalDAV,
|
parseTasksFromCalDAV,
|
||||||
} from "../caldav.js";
|
} from "../caldav.js";
|
||||||
|
import { escapeICalText, generateUUID as generateUID } from "../utils.js";
|
||||||
|
|
||||||
export const tasksToolModule: ToolModule = {
|
export const tasksToolModule: ToolModule = {
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -132,12 +132,12 @@ VERSION:2.0
|
|||||||
PRODID:-//Nextcloud MCP Server//EN
|
PRODID:-//Nextcloud MCP Server//EN
|
||||||
BEGIN:VTODO
|
BEGIN:VTODO
|
||||||
UID:${uid}
|
UID:${uid}
|
||||||
SUMMARY:${summary}
|
SUMMARY:${escapeICalText(summary)}
|
||||||
STATUS:NEEDS-ACTION
|
STATUS:NEEDS-ACTION
|
||||||
CREATED:${formatICalDateTimeUtc(new Date())}`;
|
CREATED:${formatICalDateTimeUtc(new Date())}`;
|
||||||
|
|
||||||
if (description) {
|
if (description) {
|
||||||
vtodo += `\nDESCRIPTION:${description}`;
|
vtodo += `\nDESCRIPTION:${escapeICalText(description)}`;
|
||||||
}
|
}
|
||||||
if (due) {
|
if (due) {
|
||||||
vtodo += `\nDUE:${formatICalDate(new Date(due))}`;
|
vtodo += `\nDUE:${formatICalDate(new Date(due))}`;
|
||||||
@@ -175,7 +175,7 @@ async function updateTask(args: any, client: NextcloudClient): Promise<ToolRespo
|
|||||||
let vtodo = String(response.data ?? "");
|
let vtodo = String(response.data ?? "");
|
||||||
|
|
||||||
if (summary) {
|
if (summary) {
|
||||||
vtodo = vtodo.replace(/SUMMARY:.*/, `SUMMARY:${summary}`);
|
vtodo = vtodo.replace(/SUMMARY:.*/, `SUMMARY:${escapeICalText(summary)}`);
|
||||||
}
|
}
|
||||||
if (status) {
|
if (status) {
|
||||||
vtodo = vtodo.replace(/STATUS:.*/, `STATUS:${status}`);
|
vtodo = vtodo.replace(/STATUS:.*/, `STATUS:${status}`);
|
||||||
@@ -213,6 +213,3 @@ async function updateTask(args: any, client: NextcloudClient): Promise<ToolRespo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateUID(): string {
|
|
||||||
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,6 +84,29 @@ export function generateUUID(): string {
|
|||||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${Math.random().toString(36).substring(2, 9)}`;
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Decode XML entities in text (& < > " ' and numeric references). & is decoded last to avoid double-decoding. */
|
||||||
|
export function decodeXmlText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
||||||
|
.replace(/&#([0-9]+);/g, (_m, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, "&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape text for iCalendar values (RFC 5545). */
|
||||||
|
export function escapeICalText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\r/g, "\n")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/;/g, "\\;")
|
||||||
|
.replace(/,/g, "\\,");
|
||||||
|
}
|
||||||
|
|
||||||
export function makeToolResponse(data: unknown, isError?: boolean): {
|
export function makeToolResponse(data: unknown, isError?: boolean): {
|
||||||
content: Array<{ type: "text"; text: string }>;
|
content: Array<{ type: "text"; text: string }>;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
|||||||
+12
-13
@@ -2,7 +2,7 @@
|
|||||||
// Full implementations will be added in subsequent steps
|
// Full implementations will be added in subsequent steps
|
||||||
|
|
||||||
import { SearchOptions, FileMetadata, TrashedFile, FileVersion, QuotaInfo } from "./types.js";
|
import { SearchOptions, FileMetadata, TrashedFile, FileVersion, QuotaInfo } from "./types.js";
|
||||||
import { normalizePath } from "./utils.js";
|
import { normalizePath, decodeXmlText } from "./utils.js";
|
||||||
|
|
||||||
// --- XML Builders ---
|
// --- XML Builders ---
|
||||||
|
|
||||||
@@ -344,7 +344,17 @@ export function parsePropfindSingleFileResponse(xml: string, basePath: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseSearchResponse(xml: string, basePath: string): FileMetadata[] {
|
export function parseSearchResponse(xml: string, basePath: string): FileMetadata[] {
|
||||||
return parsePropfindFilesResponse(xml, basePath);
|
// SEARCH responses don't include a root folder element (unlike PROPFIND),
|
||||||
|
// so we must NOT skip the first <response>.
|
||||||
|
const files: FileMetadata[] = [];
|
||||||
|
const responseMatches = xml.matchAll(/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g);
|
||||||
|
|
||||||
|
for (const match of responseMatches) {
|
||||||
|
const meta = parseFileMetadataFromBlock(match[0], basePath);
|
||||||
|
if (meta) files.push(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PROPFIND trashbin → array TrashedFile.
|
/** PROPFIND trashbin → array TrashedFile.
|
||||||
@@ -422,17 +432,6 @@ function escapeXml(str: string): string {
|
|||||||
.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 {
|
function resolveRelativePathFromHref(href: string, basePath: string): string {
|
||||||
const decoded = decodeURIComponent(href);
|
const decoded = decodeURIComponent(href);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user