nextcloud-mcp-lite 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.5
2
+ Name: nextcloud-mcp-lite
3
+ Version: 0.1.0
4
+ Summary: Minimal MCP server for Nextcloud file sync and PIM
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: httpx<0.29,>=0.28.1
8
+ Requires-Dist: icalendar<7.4,>=7.3.0
9
+ Requires-Dist: mcp[cli]<3,>=2.1
File without changes
@@ -0,0 +1,212 @@
1
+ """MCP tools for Nextcloud Calendar operations."""
2
+
3
+ import logging
4
+ import uuid
5
+ from datetime import datetime, timedelta
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from mcp.server.mcpserver import MCPServer
10
+ from mcp.types import ToolAnnotations
11
+
12
+ from nextcloud_mcp.client import NextcloudClient
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def configure_calendar_tools(mcp: MCPServer, client: NextcloudClient) -> None:
18
+ """Register all Calendar tools."""
19
+
20
+ @mcp.tool(
21
+ title="List Calendars",
22
+ annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
23
+ )
24
+ async def nc_list_calendars() -> dict[str, Any]:
25
+ """List all calendars for the current user."""
26
+ try:
27
+ calendars = await client.list_calendars()
28
+ return {"calendars": calendars}
29
+ except Exception as e:
30
+ return {"error": str(e)}
31
+
32
+ @mcp.tool(
33
+ title="List Events",
34
+ annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
35
+ )
36
+ async def nc_list_events(
37
+ calendar_path: str,
38
+ start: str | None = None,
39
+ end: str | None = None,
40
+ ) -> dict[str, Any]:
41
+ """List events in a calendar, optionally filtered by date range (ISO format: 2024-01-15T10:00:00)."""
42
+ try:
43
+ # Convert ISO dates to CalDAV format (YYYYMMDDTHHMMSSZ)
44
+ cal_start = None
45
+ cal_end = None
46
+ if start:
47
+ cal_start = datetime.fromisoformat(start).strftime('%Y%m%dT%H%M%SZ')
48
+ if end:
49
+ cal_end = datetime.fromisoformat(end).strftime('%Y%m%dT%H%M%SZ')
50
+ events = await client.get_events(calendar_path, cal_start, cal_end)
51
+ return {"calendar": calendar_path, "events": events}
52
+ except Exception as e:
53
+ return {"error": str(e)}
54
+
55
+ @mcp.tool(
56
+ title="Get Event",
57
+ annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
58
+ )
59
+ async def nc_get_event(calendar_path: str, event_uid: str) -> dict[str, Any]:
60
+ """Get a single event by its UID."""
61
+ try:
62
+ path = calendar_path if calendar_path.endswith("/") else calendar_path + "/"
63
+ url = f"{client.base_url}{path}{event_uid}.ics"
64
+ async with httpx.AsyncClient(
65
+ auth=client.auth, verify=client.verify, timeout=30
66
+ ) as http:
67
+ response = await http.get(url)
68
+ response.raise_for_status()
69
+ return {"uid": event_uid, "vcalendar": response.text}
70
+ except Exception as e:
71
+ return {"error": str(e)}
72
+
73
+ @mcp.tool(
74
+ title="Create Event",
75
+ annotations=ToolAnnotations(idempotent_hint=False, open_world_hint=True),
76
+ )
77
+ async def nc_create_event(
78
+ calendar_path: str,
79
+ summary: str,
80
+ start: str,
81
+ end: str | None = None,
82
+ description: str = "",
83
+ location: str = "",
84
+ ) -> dict[str, Any]:
85
+ """Create a new calendar event. Start/end in ISO format (e.g. 2024-01-15T10:00:00)."""
86
+ try:
87
+ uid = str(uuid.uuid4())
88
+ dtstart = datetime.fromisoformat(start)
89
+ dtend = datetime.fromisoformat(end) if end else dtstart + timedelta(hours=1)
90
+
91
+ vcal = f"""BEGIN:VCALENDAR
92
+ VERSION:2.0
93
+ PRODID:-//Nextcloud MCP//EN
94
+ BEGIN:VEVENT
95
+ UID:{uid}
96
+ DTSTART:{dtstart.strftime('%Y%m%dT%H%M%S')}
97
+ DTEND:{dtend.strftime('%Y%m%dT%H%M%S')}
98
+ SUMMARY:{summary}
99
+ DESCRIPTION:{description}
100
+ LOCATION:{location}
101
+ END:VEVENT
102
+ END:VCALENDAR"""
103
+
104
+ await client.create_event(calendar_path, vcal)
105
+ return {"status": "ok", "uid": uid, "summary": summary}
106
+ except Exception as e:
107
+ return {"error": str(e)}
108
+
109
+ @mcp.tool(
110
+ title="Update Event",
111
+ annotations=ToolAnnotations(idempotent_hint=False, open_world_hint=True),
112
+ )
113
+ async def nc_update_event(
114
+ calendar_path: str,
115
+ event_uid: str,
116
+ summary: str | None = None,
117
+ start: str | None = None,
118
+ end: str | None = None,
119
+ description: str | None = None,
120
+ ) -> dict[str, Any]:
121
+ """Update an existing event. Only provided fields are changed."""
122
+ try:
123
+ # Fetch existing event
124
+ result = await nc_get_event(calendar_path, event_uid)
125
+ if "error" in result:
126
+ return result
127
+
128
+ vcal = result["vcalendar"]
129
+ # Simple field replacement
130
+ if summary:
131
+ vcal = vcal.replace(
132
+ vcal[vcal.find("SUMMARY:"):vcal.find("\n", vcal.find("SUMMARY:"))],
133
+ f"SUMMARY:{summary}",
134
+ )
135
+ if description is not None:
136
+ vcal = vcal.replace(
137
+ vcal[vcal.find("DESCRIPTION:"):vcal.find("\n", vcal.find("DESCRIPTION:"))],
138
+ f"DESCRIPTION:{description}",
139
+ )
140
+ if start:
141
+ dtstart = datetime.fromisoformat(start)
142
+ vcal = vcal.replace(
143
+ vcal[vcal.find("DTSTART:"):vcal.find("\n", vcal.find("DTSTART:"))],
144
+ f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%S')}",
145
+ )
146
+ if end:
147
+ dtend = datetime.fromisoformat(end)
148
+ vcal = vcal.replace(
149
+ vcal[vcal.find("DTEND:"):vcal.find("\n", vcal.find("DTEND:"))],
150
+ f"DTEND:{dtend.strftime('%Y%m%dT%H%M%S')}",
151
+ )
152
+
153
+ # Update in place (preserves UID)
154
+ await client.update_event(calendar_path, event_uid, vcal)
155
+ return {"status": "ok", "uid": event_uid}
156
+ except Exception as e:
157
+ return {"error": str(e)}
158
+
159
+ @mcp.tool(
160
+ title="Delete Event",
161
+ annotations=ToolAnnotations(destructive_hint=True, idempotent_hint=True, open_world_hint=True),
162
+ )
163
+ async def nc_delete_event(calendar_path: str, event_uid: str) -> dict[str, Any]:
164
+ """Delete a calendar event."""
165
+ try:
166
+ await client.delete_event(calendar_path, event_uid)
167
+ return {"status": "ok", "uid": event_uid}
168
+ except Exception as e:
169
+ return {"error": str(e)}
170
+
171
+ @mcp.tool(
172
+ title="List Todos",
173
+ annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
174
+ )
175
+ async def nc_list_todos(calendar_path: str) -> dict[str, Any]:
176
+ """List todo tasks in a calendar."""
177
+ try:
178
+ todos = await client.list_todos(calendar_path)
179
+ return {"calendar": calendar_path, "todos": todos}
180
+ except Exception as e:
181
+ return {"error": str(e)}
182
+
183
+ @mcp.tool(
184
+ title="Create Todo",
185
+ annotations=ToolAnnotations(idempotent_hint=False, open_world_hint=True),
186
+ )
187
+ async def nc_create_todo(
188
+ calendar_path: str,
189
+ summary: str,
190
+ due: str | None = None,
191
+ description: str = "",
192
+ ) -> dict[str, Any]:
193
+ """Create a new todo task. Due in ISO format."""
194
+ try:
195
+ uid = str(uuid.uuid4())
196
+ due_line = f"DUE:{datetime.fromisoformat(due).strftime('%Y%m%dT%H%M%S')}" if due else ""
197
+
198
+ vcal = f"""BEGIN:VCALENDAR
199
+ VERSION:2.0
200
+ PRODID:-//Nextcloud MCP//EN
201
+ BEGIN:VTODO
202
+ UID:{uid}
203
+ SUMMARY:{summary}
204
+ DESCRIPTION:{description}
205
+ {due_line}
206
+ END:VTODO
207
+ END:VCALENDAR"""
208
+
209
+ await client.create_todo(calendar_path, vcal)
210
+ return {"status": "ok", "uid": uid, "summary": summary}
211
+ except Exception as e:
212
+ return {"error": str(e)}
@@ -0,0 +1,86 @@
1
+ """CLI for the minimal Nextcloud MCP server."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ import click
7
+
8
+ from nextcloud_mcp.config import Settings
9
+ from nextcloud_mcp.server import create_server
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @click.command()
15
+ @click.option(
16
+ "--host", "-h", default="127.0.0.1", show_default=True, help="Server host (HTTP mode only)"
17
+ )
18
+ @click.option(
19
+ "--port", "-p", type=int, default=8000, show_default=True, help="Server port (HTTP mode only)"
20
+ )
21
+ @click.option(
22
+ "--transport",
23
+ "-t",
24
+ default="stdio",
25
+ show_default=True,
26
+ type=click.Choice(["stdio", "streamable-http"]),
27
+ help="MCP transport protocol",
28
+ )
29
+ def run(
30
+ host: str,
31
+ port: int,
32
+ transport: str,
33
+ ):
34
+ """Run the Nextcloud MCP server.
35
+
36
+ Authentication: set NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD
37
+ (use an app password from Nextcloud Settings > Security > Devices & sessions).
38
+
39
+ Examples:
40
+ # stdio mode (Claude Code, local MCP clients)
41
+ $ NEXTCLOUD_HOST=https://cloud.example.com \\
42
+ NEXTCLOUD_USERNAME=user \\
43
+ NEXTCLOUD_PASSWORD=xxxx-yyyy-zzzz \\
44
+ nextcloud-mcp run --transport stdio
45
+
46
+ # HTTP mode (connect from other clients)
47
+ $ nextcloud-mcp run --transport streamable-http --host 127.0.0.1 --port 8000
48
+ """
49
+ logging.basicConfig(
50
+ level=logging.INFO,
51
+ format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
52
+ )
53
+
54
+ try:
55
+ settings = Settings.from_env()
56
+ except ValueError as e:
57
+ click.echo(f"Configuration error: {e}", err=True)
58
+ sys.exit(1)
59
+
60
+ click.echo(
61
+ f"Starting Nextcloud MCP server ({transport}) connected to {settings.nextcloud_host}",
62
+ err=True,
63
+ )
64
+
65
+ server = create_server(settings)
66
+
67
+ if transport == "stdio":
68
+ server.run(transport="stdio")
69
+ else:
70
+ import uvicorn
71
+
72
+ app = server.streamable_http_app()
73
+ uvicorn.run(app=app, host=host, port=port, log_level="info")
74
+
75
+
76
+ @click.group()
77
+ def cli():
78
+ """Minimal MCP server for Nextcloud file sync and PIM."""
79
+ pass
80
+
81
+
82
+ cli.add_command(run)
83
+
84
+
85
+ if __name__ == "__main__":
86
+ cli()
@@ -0,0 +1,374 @@
1
+ """Minimal Nextcloud client using WebDAV and CalDAV."""
2
+
3
+ import base64
4
+ import logging
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from nextcloud_mcp.config import Settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class NextcloudClient:
15
+ """HTTP client for Nextcloud WebDAV, CalDAV, and OCS APIs."""
16
+
17
+ def __init__(self, settings: Settings):
18
+ self.settings = settings
19
+ self.base_url = settings.nextcloud_host
20
+ self.auth = (settings.nextcloud_username, settings.nextcloud_password)
21
+ self.verify = settings.verify_ssl
22
+
23
+ def _client(self, **kwargs) -> httpx.AsyncClient:
24
+ """Create an async HTTP client with auth and default settings."""
25
+ return httpx.AsyncClient(
26
+ auth=self.auth,
27
+ verify=self.verify,
28
+ timeout=kwargs.get("timeout", 30),
29
+ **{k: v for k, v in kwargs.items() if k != "timeout"},
30
+ )
31
+
32
+ @property
33
+ def webdav_base(self) -> str:
34
+ """WebDAV endpoint base URL."""
35
+ return f"{self.base_url}/remote.php/dav/files/{self.settings.nextcloud_username}"
36
+
37
+ # =========================================================================
38
+ # WebDAV Operations
39
+ # =========================================================================
40
+
41
+ async def propfind(self, path: str = "/", depth: int = 1) -> list[dict[str, Any]]:
42
+ """List directory contents via PROPFIND."""
43
+ url = f"{self.webdav_base}{path}"
44
+ async with self._client() as client:
45
+ response = await client.request(
46
+ "PROPFIND",
47
+ url,
48
+ headers={"Depth": str(depth), "Content-Type": "application/xml"},
49
+ content='<?xml version="1.0" encoding="UTF-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:getcontenttype/><d:getcontentlength/><d:getlastmodified/><d:resourcetype/></d:prop></d:propfind>',
50
+ )
51
+ response.raise_for_status()
52
+ return self._parse_propfind(response.text)
53
+
54
+ async def get_file(self, path: str) -> bytes:
55
+ """Download file content."""
56
+ url = f"{self.webdav_base}{path}"
57
+ async with self._client(timeout=60) as client:
58
+ response = await client.get(url)
59
+ response.raise_for_status()
60
+ return response.content
61
+
62
+ async def put_file(self, path: str, content: bytes) -> None:
63
+ """Upload/create a file."""
64
+ url = f"{self.webdav_base}{path}"
65
+ async with self._client(timeout=60) as client:
66
+ response = await client.put(url, content=content)
67
+ response.raise_for_status()
68
+
69
+ async def delete(self, path: str) -> None:
70
+ """Delete a file or directory."""
71
+ url = f"{self.webdav_base}{path}"
72
+ async with self._client() as client:
73
+ response = await client.delete(url)
74
+ response.raise_for_status()
75
+
76
+ async def move(self, source: str, destination: str) -> None:
77
+ """Move or rename a file/directory."""
78
+ source_url = f"{self.webdav_base}{source}"
79
+ dest_url = f"{self.webdav_base}{destination}"
80
+ async with self._client() as client:
81
+ response = await client.request(
82
+ "MOVE",
83
+ source_url,
84
+ headers={"Destination": dest_url, "Overwrite": "F"},
85
+ )
86
+ response.raise_for_status()
87
+
88
+ async def copy(self, source: str, destination: str) -> None:
89
+ """Copy a file/directory."""
90
+ source_url = f"{self.webdav_base}{source}"
91
+ dest_url = f"{self.webdav_base}{destination}"
92
+ async with self._client() as client:
93
+ response = await client.request(
94
+ "COPY",
95
+ source_url,
96
+ headers={"Destination": dest_url, "Overwrite": "F"},
97
+ )
98
+ response.raise_for_status()
99
+
100
+ async def mkcol(self, path: str) -> None:
101
+ """Create a directory (MKCOL)."""
102
+ url = f"{self.webdav_base}{path}"
103
+ async with self._client() as client:
104
+ response = await client.request("MKCOL", url)
105
+ response.raise_for_status()
106
+
107
+ async def get_file_id(self, path: str) -> int | None:
108
+ """Get the file ID for a given path via PROPFIND."""
109
+ url = f"{self.webdav_base}{path}"
110
+ async with self._client() as client:
111
+ response = await client.request(
112
+ "PROPFIND",
113
+ url,
114
+ headers={"Depth": "0", "Content-Type": "application/xml"},
115
+ content='<?xml version="1.0" encoding="UTF-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:fileid/></d:prop></d:propfind>',
116
+ )
117
+ response.raise_for_status()
118
+ # Parse fileid from response
119
+ import xml.etree.ElementTree as ET
120
+ root = ET.fromstring(response.text)
121
+ for elem in root.iter():
122
+ if elem.tag.endswith("fileid"):
123
+ return int(elem.text) if elem.text else None
124
+ return None
125
+
126
+ # =========================================================================
127
+ # OCS Share API
128
+ # =========================================================================
129
+
130
+ async def create_public_link(self, path: str, permissions: int = 1) -> dict[str, Any]:
131
+ """Create a public download link for a file/folder."""
132
+ url = f"{self.base_url}/ocs/v2.php/apps/api_sharing/api/v1/shares"
133
+ async with self._client() as client:
134
+ response = await client.post(
135
+ url,
136
+ data={
137
+ "path": path,
138
+ "shareType": 3, # Public link
139
+ "permissions": permissions,
140
+ },
141
+ headers={"OCS-APIRequest": "true"},
142
+ )
143
+ response.raise_for_status()
144
+ return self._parse_ocs_response(response.json())
145
+
146
+ async def list_shares(self) -> list[dict[str, Any]]:
147
+ """List all shares for the current user."""
148
+ url = f"{self.base_url}/ocs/v2.php/apps/api_sharing/api/v1/shares"
149
+ async with self._client() as client:
150
+ response = await client.get(
151
+ url,
152
+ headers={"OCS-APIRequest": "true"},
153
+ )
154
+ response.raise_for_status()
155
+ data = self._parse_ocs_response(response.json())
156
+ return data if isinstance(data, list) else [data]
157
+
158
+ async def get_share(self, share_id: int) -> dict[str, Any]:
159
+ """Get details of a specific share."""
160
+ url = f"{self.base_url}/ocs/v2.php/apps/api_sharing/api/v1/shares/{share_id}"
161
+ async with self._client() as client:
162
+ response = await client.get(
163
+ url,
164
+ headers={"OCS-APIRequest": "true"},
165
+ )
166
+ response.raise_for_status()
167
+ return self._parse_ocs_response(response.json())
168
+
169
+ async def update_share(self, share_id: int, **kwargs) -> dict[str, Any]:
170
+ """Update a share (permissions, password, expireDate, etc.)."""
171
+ url = f"{self.base_url}/ocs/v2.php/apps/api_sharing/api/v1/shares/{share_id}"
172
+ async with self._client() as client:
173
+ response = await client.put(
174
+ url,
175
+ data=kwargs,
176
+ headers={"OCS-APIRequest": "true"},
177
+ )
178
+ response.raise_for_status()
179
+ return self._parse_ocs_response(response.json())
180
+
181
+ # =========================================================================
182
+ # CalDAV Operations
183
+ # =========================================================================
184
+
185
+ async def list_calendars(self) -> list[dict[str, Any]]:
186
+ """List all calendars."""
187
+ url = f"{self.base_url}/remote.php/dav/calendars/{self.settings.nextcloud_username}/"
188
+ async with self._client() as client:
189
+ response = await client.request(
190
+ "PROPFIND",
191
+ url,
192
+ headers={"Depth": "1", "Content-Type": "application/xml"},
193
+ content='<?xml version="1.0" encoding="UTF-8"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><d:displayname/><d:resourcetype/><cs:getctag/></d:prop></d:propfind>',
194
+ )
195
+ response.raise_for_status()
196
+ return self._parse_calendars(response.text)
197
+
198
+ async def get_events(self, calendar_path: str, start: str = None, end: str = None) -> list[dict[str, Any]]:
199
+ """Get events from a calendar, optionally filtered by date range."""
200
+ path = calendar_path if calendar_path.endswith("/") else calendar_path + "/"
201
+ url = f"{self.base_url}{path}"
202
+ filter_xml = ""
203
+ if start and end:
204
+ filter_xml = f'<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"><c:time-range start="{start}" end="{end}"/></c:comp-filter></c:comp-filter></c:filter>'
205
+
206
+ async with self._client() as client:
207
+ response = await client.request(
208
+ "REPORT",
209
+ url,
210
+ headers={"Depth": "1", "Content-Type": "application/xml"},
211
+ content=f'<?xml version="1.0" encoding="UTF-8"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getcontenttype/><d:getetag/><c:calendar-data/></d:prop>{filter_xml}</c:calendar-query>',
212
+ )
213
+ response.raise_for_status()
214
+ return self._parse_calendar_data(response.text)
215
+
216
+ async def create_event(self, calendar_path: str, vcalendar: str) -> str:
217
+ """Create a new calendar event. Returns the event UID."""
218
+ import uuid
219
+ event_uid = str(uuid.uuid4())
220
+ path = calendar_path if calendar_path.endswith("/") else calendar_path + "/"
221
+ url = f"{self.base_url}{path}{event_uid}.ics"
222
+ async with self._client() as client:
223
+ response = await client.put(
224
+ url,
225
+ content=vcalendar.encode("utf-8"),
226
+ headers={"Content-Type": "text/calendar; charset=utf-8"},
227
+ )
228
+ response.raise_for_status()
229
+ return event_uid
230
+
231
+ async def update_event(self, calendar_path: str, event_uid: str, vcalendar: str) -> None:
232
+ """Update an existing calendar event (preserves UID)."""
233
+ path = calendar_path if calendar_path.endswith("/") else calendar_path + "/"
234
+ url = f"{self.base_url}{path}{event_uid}.ics"
235
+ async with self._client() as client:
236
+ response = await client.put(
237
+ url,
238
+ content=vcalendar.encode("utf-8"),
239
+ headers={"Content-Type": "text/calendar; charset=utf-8", "Overwrite": "T"},
240
+ )
241
+ response.raise_for_status()
242
+
243
+ async def delete_event(self, calendar_path: str, event_uid: str) -> None:
244
+ """Delete a calendar event."""
245
+ path = calendar_path if calendar_path.endswith("/") else calendar_path + "/"
246
+ url = f"{self.base_url}{path}{event_uid}.ics"
247
+ async with self._client() as client:
248
+ response = await client.delete(url)
249
+ response.raise_for_status()
250
+
251
+ async def list_todos(self, calendar_path: str) -> list[dict[str, Any]]:
252
+ """List todo tasks from a calendar."""
253
+ path = calendar_path if calendar_path.endswith("/") else calendar_path + "/"
254
+ url = f"{self.base_url}{path}"
255
+ async with self._client() as client:
256
+ response = await client.request(
257
+ "REPORT",
258
+ url,
259
+ headers={"Depth": "1", "Content-Type": "application/xml"},
260
+ content='<?xml version="1.0" encoding="UTF-8"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getcontenttype/><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>',
261
+ )
262
+ response.raise_for_status()
263
+ return self._parse_calendar_data(response.text)
264
+
265
+ async def create_todo(self, calendar_path: str, vcalendar: str) -> str:
266
+ """Create a new todo task. Returns the todo UID."""
267
+ import uuid
268
+ todo_uid = str(uuid.uuid4())
269
+ path = calendar_path if calendar_path.endswith("/") else calendar_path + "/"
270
+ url = f"{self.base_url}{path}{todo_uid}.ics"
271
+ async with self._client() as client:
272
+ response = await client.put(
273
+ url,
274
+ content=vcalendar.encode("utf-8"),
275
+ headers={"Content-Type": "text/calendar; charset=utf-8"},
276
+ )
277
+ response.raise_for_status()
278
+ return todo_uid
279
+
280
+ # =========================================================================
281
+ # Parsing Helpers
282
+ # =========================================================================
283
+
284
+ def _parse_propfind(self, xml: str) -> list[dict[str, Any]]:
285
+ """Parse WebDAV PROPFIND response."""
286
+ import xml.etree.ElementTree as ET
287
+ results = []
288
+ try:
289
+ root = ET.fromstring(xml)
290
+ for response in root.findall(".//{DAV:}response"):
291
+ href = response.find(".//{DAV:}href")
292
+ if href is None:
293
+ continue
294
+ href_text = href.text or ""
295
+
296
+ propstat = response.find(".//{DAV:}propstat")
297
+ if propstat is None:
298
+ continue
299
+ prop = propstat.find(".//{DAV:}prop")
300
+ if prop is None:
301
+ continue
302
+
303
+ status = propstat.find(".//{DAV:}status")
304
+ if status is not None and b"404" in status.text.encode() if status.text else False:
305
+ continue
306
+
307
+ displayname = prop.find(".//{DAV:}displayname")
308
+ content_type = prop.find(".//{DAV:}getcontenttype")
309
+ content_length = prop.find(".//{DAV:}getcontentlength")
310
+ last_modified = prop.find(".//{DAV:}getlastmodified")
311
+ resource_type = prop.find(".//{DAV:}resourcetype")
312
+
313
+ is_collection = resource_type is not None and resource_type.find(".//{DAV:}collection") is not None
314
+ name = displayname.text if displayname is not None and displayname.text else href_text.rstrip("/").rsplit("/", 1)[-1] if href_text else ""
315
+
316
+ results.append({
317
+ "path": href_text,
318
+ "name": name,
319
+ "is_dir": is_collection,
320
+ "content_type": content_type.text if content_type is not None else "",
321
+ "size": int(content_length.text) if content_length is not None and content_length.text else 0,
322
+ "modified": last_modified.text if last_modified is not None else "",
323
+ })
324
+ except ET.ParseError as e:
325
+ logger.error("Failed to parse PROPFIND response: %s", e)
326
+ return results
327
+
328
+ def _parse_ocs_response(self, data: dict) -> Any:
329
+ """Parse OCS API response."""
330
+ try:
331
+ return data["ocs"]["data"]
332
+ except (KeyError, TypeError):
333
+ return data
334
+
335
+ def _parse_calendars(self, xml: str) -> list[dict[str, Any]]:
336
+ """Parse CalDAV calendar list PROPFIND response."""
337
+ import xml.etree.ElementTree as ET
338
+ results = []
339
+ try:
340
+ root = ET.fromstring(xml)
341
+ for response in root.findall(".//{DAV:}response"):
342
+ resource_type = response.find(".//{DAV:}resourcetype")
343
+ if resource_type is None:
344
+ continue
345
+ # Skip principals
346
+ if resource_type.find(".//{DAV:}principal") is not None:
347
+ continue
348
+
349
+ href = response.find(".//{DAV:}href")
350
+ displayname = response.find(".//{DAV:}displayname")
351
+ ctag = response.find(".//{http://calendarserver.org/ns/}getctag")
352
+
353
+ results.append({
354
+ "path": href.text or "",
355
+ "name": displayname.text if displayname is not None and displayname.text else (href.text.rstrip("/").rsplit("/", 1)[-1] if href is not None else ""),
356
+ "ctag": ctag.text if ctag is not None else "",
357
+ })
358
+ except ET.ParseError as e:
359
+ logger.error("Failed to parse calendars: %s", e)
360
+ return results
361
+
362
+ def _parse_calendar_data(self, xml: str) -> list[dict[str, Any]]:
363
+ """Parse CalDAV calendar-data responses."""
364
+ import xml.etree.ElementTree as ET
365
+ results = []
366
+ try:
367
+ root = ET.fromstring(xml)
368
+ for response in root.findall(".//{DAV:}response"):
369
+ cal_data = response.find(".//{urn:ietf:params:xml:ns:caldav}calendar-data")
370
+ if cal_data is not None and cal_data.text:
371
+ results.append({"vcalendar": cal_data.text})
372
+ except ET.ParseError as e:
373
+ logger.error("Failed to parse calendar data: %s", e)
374
+ return results
@@ -0,0 +1,34 @@
1
+ """Configuration for the minimal Nextcloud MCP server."""
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+
6
+
7
+ @dataclass
8
+ class Settings:
9
+ """Simple settings from environment variables."""
10
+ nextcloud_host: str
11
+ nextcloud_username: str
12
+ nextcloud_password: str
13
+ verify_ssl: bool = True
14
+
15
+ @classmethod
16
+ def from_env(cls) -> "Settings":
17
+ host = os.environ.get("NEXTCLOUD_HOST", "").rstrip("/")
18
+ username = os.environ.get("NEXTCLOUD_USERNAME", "")
19
+ password = os.environ.get("NEXTCLOUD_PASSWORD", "")
20
+ verify_ssl = os.environ.get("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
21
+
22
+ if not host:
23
+ raise ValueError("NEXTCLOUD_HOST environment variable is required")
24
+ if not username:
25
+ raise ValueError("NEXTCLOUD_USERNAME environment variable is required")
26
+ if not password:
27
+ raise ValueError("NEXTCLOUD_PASSWORD environment variable is required")
28
+
29
+ return cls(
30
+ nextcloud_host=host,
31
+ nextcloud_username=username,
32
+ nextcloud_password=password,
33
+ verify_ssl=verify_ssl,
34
+ )
@@ -0,0 +1,27 @@
1
+ """MCP server setup for the minimal Nextcloud MCP."""
2
+
3
+ import logging
4
+
5
+ from mcp.server.mcpserver import MCPServer
6
+
7
+ from nextcloud_mcp.config import Settings
8
+ from nextcloud_mcp.client import NextcloudClient
9
+ from nextcloud_mcp.webdav import configure_webdav_tools
10
+ from nextcloud_mcp.calendar import configure_calendar_tools
11
+ from nextcloud_mcp.sharing import configure_sharing_tools
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def create_server(settings: Settings) -> MCPServer:
17
+ """Create and configure the MCP server."""
18
+ client = NextcloudClient(settings)
19
+
20
+ mcp = MCPServer("Nextcloud MCP")
21
+
22
+ # Register all tools
23
+ configure_webdav_tools(mcp, client)
24
+ configure_calendar_tools(mcp, client)
25
+ configure_sharing_tools(mcp, client)
26
+
27
+ return mcp
@@ -0,0 +1,137 @@
1
+ """MCP tools for Nextcloud Sharing operations."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ import httpx
7
+ from mcp.server.mcpserver import MCPServer
8
+ from mcp.types import ToolAnnotations
9
+
10
+ from nextcloud_mcp.client import NextcloudClient
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def configure_sharing_tools(mcp: MCPServer, client: NextcloudClient) -> None:
16
+ """Register all Sharing tools."""
17
+
18
+ @mcp.tool(
19
+ title="Create Public Link",
20
+ annotations=ToolAnnotations(idempotent_hint=False, open_world_hint=True),
21
+ )
22
+ async def nc_create_public_link(
23
+ path: str,
24
+ permissions: int = 1,
25
+ password: str | None = None,
26
+ expire_date: str | None = None,
27
+ ) -> dict[str, Any]:
28
+ """Create a public download link for a file or folder.
29
+ permissions: 1=read, 2=update, 4=create, 8=delete, 16=share (can combine, e.g. 3=read+update)
30
+ expire_date: ISO format date (e.g. 2024-12-31)
31
+ """
32
+ try:
33
+ kwargs: dict[str, Any] = {
34
+ "path": path,
35
+ "shareType": 3, # Public link
36
+ "permissions": permissions,
37
+ }
38
+ if password:
39
+ kwargs["password"] = password
40
+ if expire_date:
41
+ kwargs["expireDate"] = expire_date
42
+
43
+ url = f"{client.base_url}/ocs/v2.php/apps/api_sharing/api/v1/shares"
44
+ async with httpx.AsyncClient(
45
+ auth=client.auth, verify=client.verify, timeout=30
46
+ ) as http:
47
+ response = await http.post(
48
+ url,
49
+ data=kwargs,
50
+ headers={"OCS-APIRequest": "true"},
51
+ )
52
+ response.raise_for_status()
53
+ data = response.json()
54
+ share = data.get("ocs", {}).get("data", {})
55
+ return {"status": "ok", "share": share}
56
+ except Exception as e:
57
+ return {"error": str(e)}
58
+
59
+ @mcp.tool(
60
+ title="List Shares",
61
+ annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
62
+ )
63
+ async def nc_list_shares() -> dict[str, Any]:
64
+ """List all shares for the current user."""
65
+ try:
66
+ url = f"{client.base_url}/ocs/v2.php/apps/api_sharing/api/v1/shares"
67
+ async with httpx.AsyncClient(
68
+ auth=client.auth, verify=client.verify, timeout=30
69
+ ) as http:
70
+ response = await http.get(
71
+ url,
72
+ headers={"OCS-APIRequest": "true"},
73
+ )
74
+ response.raise_for_status()
75
+ data = response.json()
76
+ shares = data.get("ocs", {}).get("data", [])
77
+ return {"shares": shares}
78
+ except Exception as e:
79
+ return {"error": str(e)}
80
+
81
+ @mcp.tool(
82
+ title="Get Share",
83
+ annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
84
+ )
85
+ async def nc_get_share(share_id: int) -> dict[str, Any]:
86
+ """Get details of a specific share."""
87
+ try:
88
+ url = f"{client.base_url}/ocs/v2.php/apps/api_sharing/api/v1/shares/{share_id}"
89
+ async with httpx.AsyncClient(
90
+ auth=client.auth, verify=client.verify, timeout=30
91
+ ) as http:
92
+ response = await http.get(
93
+ url,
94
+ headers={"OCS-APIRequest": "true"},
95
+ )
96
+ response.raise_for_status()
97
+ data = response.json()
98
+ share = data.get("ocs", {}).get("data", {})
99
+ return {"share": share}
100
+ except Exception as e:
101
+ return {"error": str(e)}
102
+
103
+ @mcp.tool(
104
+ title="Update Share",
105
+ annotations=ToolAnnotations(idempotent_hint=False, open_world_hint=True),
106
+ )
107
+ async def nc_update_share(
108
+ share_id: int,
109
+ permissions: int | None = None,
110
+ password: str | None = None,
111
+ expire_date: str | None = None,
112
+ ) -> dict[str, Any]:
113
+ """Update a share (permissions, password, expiry date)."""
114
+ try:
115
+ kwargs: dict[str, Any] = {}
116
+ if permissions is not None:
117
+ kwargs["permissions"] = permissions
118
+ if password is not None:
119
+ kwargs["password"] = password
120
+ if expire_date is not None:
121
+ kwargs["expireDate"] = expire_date
122
+
123
+ url = f"{client.base_url}/ocs/v2.php/apps/api_sharing/api/v1/shares/{share_id}"
124
+ async with httpx.AsyncClient(
125
+ auth=client.auth, verify=client.verify, timeout=30
126
+ ) as http:
127
+ response = await http.put(
128
+ url,
129
+ data=kwargs,
130
+ headers={"OCS-APIRequest": "true"},
131
+ )
132
+ response.raise_for_status()
133
+ data = response.json()
134
+ share = data.get("ocs", {}).get("data", {})
135
+ return {"status": "ok", "share": share}
136
+ except Exception as e:
137
+ return {"error": str(e)}
@@ -0,0 +1,191 @@
1
+ """MCP tools for Nextcloud WebDAV file operations."""
2
+
3
+ import base64
4
+ import logging
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from mcp.server.mcpserver import MCPServer
9
+ from mcp.types import ToolAnnotations
10
+
11
+ from nextcloud_mcp.client import NextcloudClient
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def configure_webdav_tools(mcp: MCPServer, client: NextcloudClient) -> None:
17
+ """Register all WebDAV file operation tools."""
18
+
19
+ @mcp.tool(
20
+ title="List Files",
21
+ annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
22
+ )
23
+ async def nc_list_files(path: str = "/") -> dict[str, Any]:
24
+ """List files and directories at the given path."""
25
+ try:
26
+ items = await client.propfind(path)
27
+ return {
28
+ "path": path,
29
+ "items": [
30
+ {
31
+ "name": item["name"],
32
+ "path": item["path"],
33
+ "is_dir": item["is_dir"],
34
+ "size": item["size"],
35
+ "content_type": item["content_type"],
36
+ "modified": item["modified"],
37
+ }
38
+ for item in items
39
+ ],
40
+ }
41
+ except Exception as e:
42
+ return {"error": str(e)}
43
+
44
+ @mcp.tool(
45
+ title="Read File",
46
+ annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
47
+ )
48
+ async def nc_read_file(path: str) -> dict[str, Any]:
49
+ """Read a file's content. Returns text or base64 for binary."""
50
+ try:
51
+ content = await client.get_file(path)
52
+ try:
53
+ text = content.decode("utf-8")
54
+ return {"path": path, "content": text, "encoding": "utf-8"}
55
+ except UnicodeDecodeError:
56
+ return {
57
+ "path": path,
58
+ "content": base64.b64encode(content).decode("ascii"),
59
+ "encoding": "base64",
60
+ }
61
+ except Exception as e:
62
+ return {"error": str(e)}
63
+
64
+ @mcp.tool(
65
+ title="Write File",
66
+ annotations=ToolAnnotations(idempotent_hint=False, open_world_hint=True),
67
+ )
68
+ async def nc_write_file(path: str, content: str, encoding: str = "utf-8") -> dict[str, Any]:
69
+ """Write content to a file (creates or overwrites)."""
70
+ try:
71
+ if encoding == "base64":
72
+ raw = base64.b64decode(content)
73
+ else:
74
+ raw = content.encode("utf-8")
75
+ await client.put_file(path, raw)
76
+ return {"status": "ok", "path": path, "bytes_written": len(raw)}
77
+ except Exception as e:
78
+ return {"error": str(e)}
79
+
80
+ @mcp.tool(
81
+ title="Create Directory",
82
+ annotations=ToolAnnotations(idempotent_hint=False, open_world_hint=True),
83
+ )
84
+ async def nc_create_directory(path: str) -> dict[str, Any]:
85
+ """Create a new directory."""
86
+ try:
87
+ await client.mkcol(path)
88
+ return {"status": "ok", "path": path}
89
+ except Exception as e:
90
+ return {"error": str(e)}
91
+
92
+ @mcp.tool(
93
+ title="Delete",
94
+ annotations=ToolAnnotations(destructive_hint=True, idempotent_hint=True, open_world_hint=True),
95
+ )
96
+ async def nc_delete(path: str) -> dict[str, Any]:
97
+ """Delete a file or directory permanently."""
98
+ try:
99
+ await client.delete(path)
100
+ return {"status": "ok", "path": path}
101
+ except Exception as e:
102
+ return {"error": str(e)}
103
+
104
+ @mcp.tool(
105
+ title="Move",
106
+ annotations=ToolAnnotations(idempotent_hint=False, open_world_hint=True),
107
+ )
108
+ async def nc_move(source: str, destination: str) -> dict[str, Any]:
109
+ """Move or rename a file or directory."""
110
+ try:
111
+ await client.move(source, destination)
112
+ return {"status": "ok", "source": source, "destination": destination}
113
+ except Exception as e:
114
+ return {"error": str(e)}
115
+
116
+ @mcp.tool(
117
+ title="Copy",
118
+ annotations=ToolAnnotations(idempotent_hint=False, open_world_hint=True),
119
+ )
120
+ async def nc_copy(source: str, destination: str) -> dict[str, Any]:
121
+ """Copy a file or directory."""
122
+ try:
123
+ await client.copy(source, destination)
124
+ return {"status": "ok", "source": source, "destination": destination}
125
+ except Exception as e:
126
+ return {"error": str(e)}
127
+
128
+ @mcp.tool(
129
+ title="Find Files",
130
+ annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
131
+ )
132
+ async def nc_find_files(path: str = "/", name_contains: str = "") -> dict[str, Any]:
133
+ """Find files by name pattern (simple substring match)."""
134
+ try:
135
+ items = await client.propfind(path, depth=1)
136
+ results = [
137
+ item for item in items
138
+ if name_contains.lower() in item["name"].lower()
139
+ ]
140
+ return {
141
+ "path": path,
142
+ "name_contains": name_contains,
143
+ "matches": [
144
+ {
145
+ "name": item["name"],
146
+ "path": item["path"],
147
+ "is_dir": item["is_dir"],
148
+ "size": item["size"],
149
+ }
150
+ for item in results
151
+ ],
152
+ }
153
+ except Exception as e:
154
+ return {"error": str(e)}
155
+
156
+ @mcp.tool(
157
+ title="Rename",
158
+ annotations=ToolAnnotations(idempotent_hint=False, open_world_hint=True),
159
+ )
160
+ async def nc_rename(path: str, new_name: str) -> dict[str, Any]:
161
+ """Rename a file or directory (same parent, new name)."""
162
+ try:
163
+ parent = path.rsplit("/", 1)[0] or "/"
164
+ destination = f"{parent}/{new_name}"
165
+ await client.move(path, destination)
166
+ return {"status": "ok", "old_path": path, "new_path": destination}
167
+ except Exception as e:
168
+ return {"error": str(e)}
169
+
170
+ @mcp.tool(
171
+ title="List Comments",
172
+ annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
173
+ )
174
+ async def nc_list_comments(path: str) -> dict[str, Any]:
175
+ """List comments for a file."""
176
+ try:
177
+ file_id = await client.get_file_id(path)
178
+ if file_id is None:
179
+ return {"error": f"Could not resolve file ID for {path}"}
180
+ # Use OCS Comments API
181
+ url = f"{client.base_url}/ocs/v2.php/apps/comments/api/v1/files/{file_id}"
182
+ async with httpx.AsyncClient(
183
+ auth=client.auth, verify=client.verify, timeout=30
184
+ ) as http:
185
+ response = await http.get(url, headers={"OCS-APIRequest": "true"})
186
+ response.raise_for_status()
187
+ data = response.json()
188
+ comments = data.get("ocs", {}).get("data", [])
189
+ return {"file_id": file_id, "comments": comments}
190
+ except Exception as e:
191
+ return {"error": str(e)}
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "nextcloud-mcp-lite"
3
+ version = "0.1.0"
4
+ description = "Minimal MCP server for Nextcloud file sync and PIM"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "mcp[cli]>=2.1,<3",
8
+ "httpx>=0.28.1,<0.29",
9
+ "click>=8.1",
10
+ "icalendar>=7.3.0,<7.4",
11
+ ]
12
+
13
+ [project.scripts]
14
+ nextcloud-mcp = "nextcloud_mcp.cli:cli"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["nextcloud_mcp"]