Clean git move from play

This commit is contained in:
Jules Grinnell
2025-11-23 16:44:05 +01:00
parent f0331de014
commit 7da30ae044
8 changed files with 3335 additions and 2 deletions

71
.gitignore vendored Normal file
View File

@@ -0,0 +1,71 @@
# Environment variables
.env
.env.local
.env.*.local
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
build/
dist/
*.tsbuildinfo
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Operating system files
Thumbs.db
Desktop.ini
# Logs
logs/
*.log
# Testing
coverage/
.nyc_output/
*.lcov
# Temporary files
tmp/
temp/
*.tmp
# Package manager lock files (optional - uncomment if you want to ignore them)
# package-lock.json
# yarn.lock
# pnpm-lock.yaml
# Debug files
*.pid
*.seed
*.pid.lock
# Runtime data
pids/
lib-cov/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity

164
QUICKSTART.md Normal file
View File

@@ -0,0 +1,164 @@
# Quick Start Guide
## 🚀 Get Started in 5 Minutes
### Step 1: Get Your Nextcloud App Password
1. Log into your Nextcloud instance
2. Go to **Settings****Security**
3. Scroll to "Devices & sessions"
4. Enter a name (e.g., "MCP Server") and click "Create new app password"
5. Copy the generated password
### Step 2: Configure the Server
Create a `.env` file in the project root:
```bash
cp .env.example .env
```
Edit `.env` with your details:
```env
NEXTCLOUD_URL=https://your-nextcloud.com
NEXTCLOUD_USERNAME=your-username
NEXTCLOUD_PASSWORD=paste-your-app-password-here
```
### Step 3: Build and Test
```bash
# Build the project
npm run build
# Test it works (Ctrl+C to exit)
npm run start
```
You should see: `Nextcloud MCP Server running on stdio`
### Step 4: Connect to Claude Desktop
Edit your Claude Desktop config:
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
Add this configuration (replace the path with your actual path):
```json
{
"mcpServers": {
"nextcloud": {
"command": "node",
"args": ["/full/path/to/nextcloud-mcp/build/index.js"],
"env": {
"NEXTCLOUD_URL": "https://your-nextcloud.com",
"NEXTCLOUD_USERNAME": "your-username",
"NEXTCLOUD_PASSWORD": "your-app-password"
}
}
}
}
```
**Important**: Use the full absolute path to the build/index.js file!
### Step 5: Restart Claude Desktop
Quit Claude Desktop completely and reopen it.
### Step 6: Test with Claude
Try these prompts in Claude:
```
"Show me my open tasks"
"What meetings do I have today?"
"List my recent notes"
"Show me my latest emails"
```
## ✅ Verification Checklist
Before asking Claude to use Nextcloud:
- [ ] Nextcloud URL is correct (HTTPS, no trailing slash)
- [ ] App password is correctly copied (no extra spaces)
- [ ] Required apps are installed:
- [ ] Tasks app
- [ ] Calendar app
- [ ] Notes app
- [ ] Mail app (if using email features)
- [ ] Full absolute path used in Claude Desktop config
- [ ] Claude Desktop has been restarted
## 🔧 Common Issues
### "Connection refused" or timeout
- Check your Nextcloud URL is accessible
- Verify you're using HTTPS
- Try accessing the URL in your browser
### "Authentication failed" or 401 error
- Regenerate a new app password
- Make sure there are no spaces before/after the password
- Try your username in lowercase
### "Calendar not found" or "Tasks not found"
- Check that you have at least one calendar created
- Verify the Tasks app is installed and has a task list
- See customization section in README for different calendar names
### Claude doesn't show Nextcloud tools
- Verify Claude Desktop config file syntax (use a JSON validator)
- Check the path to build/index.js is correct and absolute
- Look at Claude Desktop logs for errors
- Restart Claude Desktop after config changes
## 🎯 Next Steps
Once working:
1. Customize calendar/task list names in `src/index.ts` if needed
2. Add more tools as needed for your workflow
3. Check out the full README.md for advanced features
## 📚 Example Use Cases
**Task Management**:
```
"Create a task to review the integration docs, due tomorrow"
"Show me all my completed tasks this week"
"Mark task X as complete"
```
**Calendar**:
```
"What's on my calendar tomorrow?"
"Schedule a meeting with the team next Monday at 2pm"
"Show me my events for next week"
```
**Notes**:
```
"Create a note with my meeting notes from today"
"Show me all my notes"
"What's in note ID 123?"
```
**Email**:
```
"Show me my latest emails"
"What are my unread messages?"
```
## 🆘 Getting Help
If you're stuck:
1. Check the logs: `~/Library/Logs/Claude/mcp*.log` (macOS)
2. Review the full README.md
3. Verify all prerequisites are met
4. Test the Nextcloud API directly using curl to isolate issues
Happy automating! 🎉

241
README.md
View File

@@ -1,2 +1,239 @@
# nextcloud-mcp # Nextcloud MCP Server
MCP server for nextcloud
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)
## 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.)
### Calendar
- ✅ Get calendar events with date range filtering
- ✅ Create new calendar events with details and location
### Notes
- ✅ Get all notes
- ✅ Create new notes with markdown support
- ✅ Get specific note content by ID
### Email
- ✅ Get emails from inbox
- 📧 Requires Nextcloud Mail app configured
## Prerequisites
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)
3. **App Password**: Generate in Nextcloud Settings > Security > Devices & sessions
## Installation
```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
```bash
# Development mode (auto-reload)
npm run dev
# Production mode
npm run start
```
### Using with Claude Desktop
Add this to your Claude Desktop configuration file:
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%\Claude\claude_desktop_config.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"
}
}
}
}
```
Or use the development version with tsx:
```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"
}
}
}
}
```
Restart Claude Desktop to load the MCP server.
## Available Tools
Once connected, Claude can use these tools:
### 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)
### Calendar
- `get_calendar_events` - Get events in date range
- `create_calendar_event` - Create new event with details
### Notes
- `get_notes` - List all notes
- `create_note` - Create new note with markdown
- `get_note_content` - Get full content of specific note
### Email
- `get_emails` - Retrieve recent emails from inbox
## Example Prompts for Claude
Once the MCP server is connected, you can ask Claude:
```
"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?"
```
## 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
### CalDAV Issues
- Verify calendar/task list names match your Nextcloud setup
- Default calendar name is "personal" - adjust in code if needed
- Default task list name is "tasks" - adjust in code if needed
### 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
### Changing Calendar Names
Edit `src/index.ts` and update the calendar paths:
```typescript
// Default personal calendar
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/personal/`;
// For a different calendar, change "personal" to your calendar name
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/work/`;
```
### 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
```bash
# Install dependencies
npm install
# Run in development mode with auto-reload
npm run watch # In one terminal
npm run dev # In another terminal
# Build for production
npm run build
```
## 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)

1792
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "nextcloud-mcp",
"version": "1.0.0",
"description": "MCP server for Nextcloud integration - tasks, calendar, notes, and email",
"type": "module",
"bin": {
"nextcloud-mcp": "./build/index.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"start": "node build/index.js",
"dev": "tsx src/index.ts",
"test-connection": "node test-connection.js"
},
"keywords": ["mcp", "nextcloud", "calendar", "tasks", "notes", "email"],
"author": "",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.20.2",
"axios": "^1.12.2",
"date-fns": "^4.1.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.9.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}

892
src/index.ts Normal file
View File

@@ -0,0 +1,892 @@
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import axios, { AxiosInstance } from "axios";
import { parseISO, format } from "date-fns";
interface NextcloudConfig {
url: string;
username: string;
password: string; // App password recommended
}
class NextcloudMCPServer {
private server: Server;
private axiosInstance: AxiosInstance;
private config: NextcloudConfig;
constructor(config: NextcloudConfig) {
this.config = config;
this.server = new Server(
{
name: "nextcloud-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Create axios instance with basic auth
this.axiosInstance = axios.create({
baseURL: config.url,
auth: {
username: config.username,
password: config.password,
},
headers: {
"Content-Type": "application/xml",
"Accept": "application/xml",
},
});
this.setupHandlers();
this.setupErrorHandling();
}
private setupErrorHandling(): void {
this.server.onerror = (error) => {
console.error("[MCP Error]", error);
};
process.on("SIGINT", async () => {
await this.server.close();
process.exit(0);
});
}
private setupHandlers(): void {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: this.getTools(),
};
});
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "get_tasks":
return await this.getTasks(args as any);
case "create_task":
return await this.createTask(args as any);
case "update_task":
return await this.updateTask(args as any);
case "get_calendar_events":
return await this.getCalendarEvents(args as any);
case "create_calendar_event":
return await this.createCalendarEvent(args as any);
case "get_notes":
return await this.getNotes(args as any);
case "create_note":
return await this.createNote(args as any);
case "get_note_content":
return await this.getNoteContent(args as any);
case "get_emails":
return await this.getEmails(args as any);
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error: any) {
return {
content: [
{
type: "text",
text: `Error: ${error.message}`,
},
],
isError: true,
};
}
});
}
private getTools(): Tool[] {
return [
// Tasks tools
{
name: "get_tasks",
description:
"Retrieve tasks from Nextcloud. Can filter by status (completed/open) and limit results.",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
enum: ["all", "open", "completed"],
description: "Filter tasks by status",
default: "all",
},
limit: {
type: "number",
description: "Maximum number of tasks to return",
default: 50,
},
},
},
},
{
name: "create_task",
description: "Create a new task in Nextcloud",
inputSchema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Task title/summary",
},
description: {
type: "string",
description: "Task description (optional)",
},
due: {
type: "string",
description: "Due date in ISO format (YYYY-MM-DD) (optional)",
},
priority: {
type: "number",
description: "Priority (1-9, where 1 is highest) (optional)",
},
},
required: ["summary"],
},
},
{
name: "update_task",
description: "Update an existing task (mark as complete, change summary, etc.)",
inputSchema: {
type: "object",
properties: {
taskId: {
type: "string",
description: "Task ID/UID",
},
summary: {
type: "string",
description: "New task title/summary (optional)",
},
status: {
type: "string",
enum: ["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"],
description: "New task status (optional)",
},
percentComplete: {
type: "number",
description: "Completion percentage 0-100 (optional)",
},
},
required: ["taskId"],
},
},
// Calendar tools
{
name: "get_calendar_events",
description:
"Retrieve calendar events from Nextcloud. Can specify date range.",
inputSchema: {
type: "object",
properties: {
startDate: {
type: "string",
description:
"Start date in ISO format (YYYY-MM-DD). Defaults to today.",
},
endDate: {
type: "string",
description:
"End date in ISO format (YYYY-MM-DD). Defaults to 30 days from start.",
},
limit: {
type: "number",
description: "Maximum number of events to return",
default: 50,
},
},
},
},
{
name: "create_calendar_event",
description: "Create a new calendar event in Nextcloud",
inputSchema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Event title/summary",
},
description: {
type: "string",
description: "Event description (optional)",
},
startDateTime: {
type: "string",
description: "Start date/time in ISO format (YYYY-MM-DDTHH:mm:ss)",
},
endDateTime: {
type: "string",
description: "End date/time in ISO format (YYYY-MM-DDTHH:mm:ss)",
},
location: {
type: "string",
description: "Event location (optional)",
},
},
required: ["summary", "startDateTime", "endDateTime"],
},
},
// Notes tools
{
name: "get_notes",
description: "Retrieve all notes from Nextcloud Notes app",
inputSchema: {
type: "object",
properties: {
limit: {
type: "number",
description: "Maximum number of notes to return",
default: 50,
},
},
},
},
{
name: "create_note",
description: "Create a new note in Nextcloud Notes app",
inputSchema: {
type: "object",
properties: {
title: {
type: "string",
description: "Note title (first line)",
},
content: {
type: "string",
description: "Note content (markdown supported)",
},
category: {
type: "string",
description: "Note category/folder (optional)",
},
},
required: ["content"],
},
},
{
name: "get_note_content",
description: "Get the full content of a specific note by ID",
inputSchema: {
type: "object",
properties: {
noteId: {
type: "number",
description: "Note ID",
},
},
required: ["noteId"],
},
},
// Email tools
{
name: "get_emails",
description:
"Retrieve emails from Nextcloud Mail app. Returns recent emails from inbox.",
inputSchema: {
type: "object",
properties: {
accountId: {
type: "number",
description: "Mail account ID (use 0 for default)",
default: 0,
},
limit: {
type: "number",
description: "Maximum number of emails to return",
default: 20,
},
},
},
},
];
}
// ========== TASKS METHODS ==========
private async getTasks(args: any) {
const status = args.status || "all";
const limit = args.limit || 50;
try {
// CalDAV REPORT request to get tasks
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/`;
const requestBody = `<?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="VTODO" />
</c:comp-filter>
</c:filter>
</c:calendar-query>`;
const response = await this.axiosInstance.request({
method: "REPORT",
url: caldavPath,
data: requestBody,
headers: {
"Content-Type": "application/xml",
Depth: "1",
},
});
const tasks = this.parseTasksFromCalDAV(response.data, status, limit);
return {
content: [
{
type: "text",
text: JSON.stringify(tasks, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to fetch tasks: ${error.message}`);
}
}
private parseTasksFromCalDAV(
xmlData: string,
status: string,
limit: number
): any[] {
const tasks: any[] = [];
// Basic XML parsing for VTODO components
const todoMatches = xmlData.matchAll(
/<c:calendar-data[^>]*>([\s\S]*?)<\/c:calendar-data>/g
);
for (const match of todoMatches) {
if (tasks.length >= limit) break;
const todoData = match[1];
const task = this.parseVTODO(todoData);
if (task) {
if (status === "all") {
tasks.push(task);
} else if (
status === "completed" &&
task.status === "COMPLETED"
) {
tasks.push(task);
} else if (
status === "open" &&
task.status !== "COMPLETED"
) {
tasks.push(task);
}
}
}
return tasks;
}
private parseVTODO(todoData: string): any | null {
const lines = todoData.split(/\r?\n/);
const task: any = {};
for (const line of lines) {
if (line.startsWith("UID:")) {
task.uid = line.substring(4).trim();
} else if (line.startsWith("SUMMARY:")) {
task.summary = line.substring(8).trim();
} else if (line.startsWith("STATUS:")) {
task.status = line.substring(7).trim();
} else if (line.startsWith("PERCENT-COMPLETE:")) {
task.percentComplete = parseInt(line.substring(17).trim());
} else if (line.startsWith("DUE")) {
const dueMatch = line.match(/DUE[^:]*:(\d{8}T?\d{6}Z?)/);
if (dueMatch) {
task.due = this.parseICalDate(dueMatch[1]);
}
} else if (line.startsWith("PRIORITY:")) {
task.priority = parseInt(line.substring(9).trim());
} else if (line.startsWith("DESCRIPTION:")) {
task.description = line.substring(12).trim();
}
}
return task.uid ? task : null;
}
private async createTask(args: any) {
const { summary, description, due, priority } = args;
const uid = this.generateUID();
let vtodo = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nextcloud MCP Server//EN
BEGIN:VTODO
UID:${uid}
SUMMARY:${summary}
STATUS:NEEDS-ACTION
CREATED:${this.formatICalDateTime(new Date())}`;
if (description) {
vtodo += `\nDESCRIPTION:${description}`;
}
if (due) {
vtodo += `\nDUE:${this.formatICalDate(new Date(due))}`;
}
if (priority) {
vtodo += `\nPRIORITY:${priority}`;
}
vtodo += `\nEND:VTODO
END:VCALENDAR`;
try {
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/${uid}.ics`;
await this.axiosInstance.put(caldavPath, vtodo, {
headers: {
"Content-Type": "text/calendar",
},
});
return {
content: [
{
type: "text",
text: `Task created successfully with UID: ${uid}`,
},
],
};
} catch (error: any) {
throw new Error(`Failed to create task: ${error.message}`);
}
}
private async updateTask(args: any) {
const { taskId, summary, status, percentComplete } = args;
// First, fetch the existing task
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/${taskId}.ics`;
try {
const response = await this.axiosInstance.get(caldavPath);
let vtodo = response.data;
// Update fields
if (summary) {
vtodo = vtodo.replace(/SUMMARY:.*/, `SUMMARY:${summary}`);
}
if (status) {
vtodo = vtodo.replace(/STATUS:.*/, `STATUS:${status}`);
}
if (percentComplete !== undefined) {
if (vtodo.includes("PERCENT-COMPLETE:")) {
vtodo = vtodo.replace(
/PERCENT-COMPLETE:.*/,
`PERCENT-COMPLETE:${percentComplete}`
);
} else {
vtodo = vtodo.replace(
/END:VTODO/,
`PERCENT-COMPLETE:${percentComplete}\nEND:VTODO`
);
}
}
// Update LAST-MODIFIED
vtodo = vtodo.replace(
/LAST-MODIFIED:.*/,
`LAST-MODIFIED:${this.formatICalDateTime(new Date())}`
);
await this.axiosInstance.put(caldavPath, vtodo, {
headers: {
"Content-Type": "text/calendar",
},
});
return {
content: [
{
type: "text",
text: `Task ${taskId} updated successfully`,
},
],
};
} catch (error: any) {
throw new Error(`Failed to update task: ${error.message}`);
}
}
// ========== CALENDAR METHODS ==========
private async getCalendarEvents(args: any) {
const startDate = args.startDate || format(new Date(), "yyyy-MM-dd");
const endDate =
args.endDate ||
format(
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
"yyyy-MM-dd"
);
const limit = args.limit || 50;
try {
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/personal/`;
const requestBody = `<?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="${this.formatICalDate(
new Date(startDate)
)}" end="${this.formatICalDate(new Date(endDate))}"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>`;
const response = await this.axiosInstance.request({
method: "REPORT",
url: caldavPath,
data: requestBody,
headers: {
"Content-Type": "application/xml",
Depth: "1",
},
});
const events = this.parseEventsFromCalDAV(response.data, limit);
return {
content: [
{
type: "text",
text: JSON.stringify(events, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to fetch calendar events: ${error.message}`);
}
}
private parseEventsFromCalDAV(xmlData: string, limit: number): any[] {
const events: any[] = [];
const eventMatches = xmlData.matchAll(
/<c:calendar-data[^>]*>([\s\S]*?)<\/c:calendar-data>/g
);
for (const match of eventMatches) {
if (events.length >= limit) break;
const eventData = match[1];
const event = this.parseVEVENT(eventData);
if (event) {
events.push(event);
}
}
return events;
}
private parseVEVENT(eventData: string): any | null {
const lines = eventData.split(/\r?\n/);
const event: any = {};
for (const line of lines) {
if (line.startsWith("UID:")) {
event.uid = line.substring(4).trim();
} else if (line.startsWith("SUMMARY:")) {
event.summary = line.substring(8).trim();
} else if (line.startsWith("DESCRIPTION:")) {
event.description = line.substring(12).trim();
} else if (line.startsWith("LOCATION:")) {
event.location = line.substring(9).trim();
} else if (line.startsWith("DTSTART")) {
const startMatch = line.match(/DTSTART[^:]*:(\d{8}T?\d{6}Z?)/);
if (startMatch) {
event.start = this.parseICalDate(startMatch[1]);
}
} else if (line.startsWith("DTEND")) {
const endMatch = line.match(/DTEND[^:]*:(\d{8}T?\d{6}Z?)/);
if (endMatch) {
event.end = this.parseICalDate(endMatch[1]);
}
}
}
return event.uid ? event : null;
}
private async createCalendarEvent(args: any) {
const { summary, description, startDateTime, endDateTime, location } = args;
const uid = this.generateUID();
let vevent = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nextcloud MCP Server//EN
BEGIN:VEVENT
UID:${uid}
SUMMARY:${summary}
DTSTART:${this.formatICalDateTime(new Date(startDateTime))}
DTEND:${this.formatICalDateTime(new Date(endDateTime))}
CREATED:${this.formatICalDateTime(new Date())}`;
if (description) {
vevent += `\nDESCRIPTION:${description}`;
}
if (location) {
vevent += `\nLOCATION:${location}`;
}
vevent += `\nEND:VEVENT
END:VCALENDAR`;
try {
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/personal/${uid}.ics`;
await this.axiosInstance.put(caldavPath, vevent, {
headers: {
"Content-Type": "text/calendar",
},
});
return {
content: [
{
type: "text",
text: `Calendar event created successfully with UID: ${uid}`,
},
],
};
} catch (error: any) {
throw new Error(`Failed to create calendar event: ${error.message}`);
}
}
// ========== NOTES METHODS ==========
private async getNotes(args: any) {
const limit = args.limit || 50;
try {
const response = await this.axiosInstance.get(
`/index.php/apps/notes/api/v1/notes`,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
}
);
const notes = response.data.slice(0, limit);
return {
content: [
{
type: "text",
text: JSON.stringify(notes, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to fetch notes: ${error.message}`);
}
}
private async createNote(args: any) {
const { title, content, category } = args;
// Nextcloud Notes uses the first line as title
const noteContent = title ? `${title}\n\n${content}` : content;
try {
const payload: any = {
content: noteContent,
};
if (category) {
payload.category = category;
}
const response = await this.axiosInstance.post(
`/index.php/apps/notes/api/v1/notes`,
payload,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
}
);
return {
content: [
{
type: "text",
text: `Note created successfully with ID: ${response.data.id}`,
},
],
};
} catch (error: any) {
throw new Error(`Failed to create note: ${error.message}`);
}
}
private async getNoteContent(args: any) {
const { noteId } = args;
try {
const response = await this.axiosInstance.get(
`/index.php/apps/notes/api/v1/notes/${noteId}`,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
}
);
return {
content: [
{
type: "text",
text: JSON.stringify(response.data, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to fetch note: ${error.message}`);
}
}
// ========== EMAIL METHODS ==========
private async getEmails(args: any) {
const accountId = args.accountId || 0;
const limit = args.limit || 20;
try {
// Get mailboxes first
const mailboxesResponse = await this.axiosInstance.get(
`/index.php/apps/mail/api/accounts/${accountId}/mailboxes`,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
}
);
// Find INBOX
const inbox = mailboxesResponse.data.find(
(mb: any) => mb.specialRole === "inbox"
);
if (!inbox) {
throw new Error("Inbox not found");
}
// Get messages from inbox
const messagesResponse = await this.axiosInstance.get(
`/index.php/apps/mail/api/messages?mailboxId=${inbox.id}`,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
}
);
const emails = messagesResponse.data.slice(0, limit);
return {
content: [
{
type: "text",
text: JSON.stringify(emails, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to fetch emails: ${error.message}`);
}
}
// ========== UTILITY METHODS ==========
private generateUID(): string {
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
}
private formatICalDate(date: Date): string {
return format(date, "yyyyMMdd");
}
private formatICalDateTime(date: Date): string {
return format(date, "yyyyMMdd'T'HHmmss'Z'");
}
private parseICalDate(icalDate: string): string {
// Parse iCal date format (e.g., 20240101 or 20240101T120000Z)
if (icalDate.includes("T")) {
const year = icalDate.substring(0, 4);
const month = icalDate.substring(4, 6);
const day = icalDate.substring(6, 8);
const hour = icalDate.substring(9, 11);
const minute = icalDate.substring(11, 13);
return `${year}-${month}-${day} ${hour}:${minute}`;
} else {
const year = icalDate.substring(0, 4);
const month = icalDate.substring(4, 6);
const day = icalDate.substring(6, 8);
return `${year}-${month}-${day}`;
}
}
async run(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Nextcloud MCP Server running on stdio");
}
}
// Main execution
const config: NextcloudConfig = {
url: process.env.NEXTCLOUD_URL || "",
username: process.env.NEXTCLOUD_USERNAME || "",
password: process.env.NEXTCLOUD_PASSWORD || "",
};
if (!config.url || !config.username || !config.password) {
console.error(
"Error: NEXTCLOUD_URL, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD environment variables are required"
);
process.exit(1);
}
const server = new NextcloudMCPServer(config);
server.run().catch(console.error);

130
test-connection.js Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Test script to verify Nextcloud connectivity
* Run with: node test-connection.js
*/
import axios from 'axios';
const config = {
url: process.env.NEXTCLOUD_URL || '',
username: process.env.NEXTCLOUD_USERNAME || '',
password: process.env.NEXTCLOUD_PASSWORD || '',
};
console.log('🔍 Testing Nextcloud Connection...\n');
console.log('Configuration:');
console.log(` URL: ${config.url}`);
console.log(` Username: ${config.username}`);
console.log(` Password: ${config.password ? '***' + config.password.slice(-4) : '(not set)'}\n`);
if (!config.url || !config.username || !config.password) {
console.error('❌ Error: Missing configuration!');
console.error('Please set NEXTCLOUD_URL, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD environment variables.');
console.error('\nYou can create a .env file with these values or export them:');
console.error(' export NEXTCLOUD_URL=https://your-nextcloud.com');
console.error(' export NEXTCLOUD_USERNAME=your-username');
console.error(' export NEXTCLOUD_PASSWORD=your-app-password\n');
process.exit(1);
}
const axiosInstance = axios.create({
baseURL: config.url,
auth: {
username: config.username,
password: config.password,
},
});
async function testConnection() {
const tests = [
{
name: 'Basic Authentication',
test: async () => {
const response = await axiosInstance.get('/ocs/v2.php/cloud/user');
return { success: true, data: response.status === 200 };
},
},
{
name: 'CalDAV (Tasks)',
test: async () => {
const response = await axiosInstance.get(
`/remote.php/dav/calendars/${config.username}/`
);
return { success: true, data: response.status === 207 || response.status === 200 };
},
},
{
name: 'Notes API',
test: async () => {
const response = await axiosInstance.get(
'/index.php/apps/notes/api/v1/notes',
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
}
);
return { success: true, data: Array.isArray(response.data) };
},
},
{
name: 'Mail API (optional)',
test: async () => {
try {
const response = await axiosInstance.get(
'/index.php/apps/mail/api/accounts',
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
}
);
return { success: true, data: Array.isArray(response.data) };
} catch (error) {
// Mail app might not be installed
return { success: false, data: 'Mail app not installed or not configured' };
}
},
},
];
console.log('Running connectivity tests...\n');
for (const { name, test } of tests) {
process.stdout.write(`Testing ${name}... `);
try {
const result = await test();
if (result.success && result.data) {
console.log('✅ PASSED');
} else {
console.log(`⚠️ WARNING: ${result.data}`);
}
} catch (error) {
console.log('❌ FAILED');
if (axios.isAxiosError(error)) {
console.log(` Error: ${error.message}`);
if (error.response) {
console.log(` Status: ${error.response.status}`);
console.log(` Details: ${error.response.statusText}`);
}
} else {
console.log(` Error: ${error}`);
}
}
}
console.log('\n🎉 Connection test complete!\n');
console.log('Next steps:');
console.log('1. If all tests passed, you can use "npm run start" to run the MCP server');
console.log('2. If tests failed, check your credentials and Nextcloud configuration');
console.log('3. Make sure the required apps (Tasks, Calendar, Notes) are installed in Nextcloud\n');
}
testConnection().catch((error) => {
console.error('\n❌ Fatal error:', error.message);
process.exit(1);
});

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}