Skip to content

Commit 8386644

Browse files
authored
Merge pull request #104 from cbcoutinho/feature/vcard
Initialize Contacts App
2 parents ad95140 + 1dfdad5 commit 8386644

File tree

14 files changed

+629
-78
lines changed

14 files changed

+629
-78
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
1616
| **Calendar** | ✅ Full Support | Complete calendar integration - create, update, delete events. Support for recurring events, reminders, attendees, and all-day events via CalDAV. |
1717
| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. |
1818
| **Files (WebDAV)** | ✅ Full Support | Complete file system access - browse directories, read/write files, create/delete resources. |
19+
| **Contacts** | ✅ Full Support | Create, read, update, and delete contacts and address books via CardDAV. |
1920

2021
## Available Tools
2122

@@ -46,6 +47,17 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
4647
| `nc_calendar_bulk_operations` | **New:** Bulk update, delete, or move events matching filter criteria |
4748
| `nc_calendar_manage_calendar` | **New:** Create, delete, and manage calendar properties |
4849

50+
### Contacts Tools
51+
52+
| Tool | Description |
53+
|------|-------------|
54+
| `nc_contacts_list_addressbooks` | List all available addressbooks for the user |
55+
| `nc_contacts_list_contacts` | List all contacts in a specific addressbook |
56+
| `nc_contacts_create_addressbook` | Create a new addressbook |
57+
| `nc_contacts_delete_addressbook` | Delete an addressbook |
58+
| `nc_contacts_create_contact` | Create a new contact in an addressbook |
59+
| `nc_contacts_delete_contact` | Delete a contact from an addressbook |
60+
4961
### Tables Tools
5062

5163
| Tool | Description |
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/bash
2+
3+
php /var/www/html/occ app:enable contacts

nextcloud_mcp_server/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from nextcloud_mcp_server.config import setup_logging
1010
from nextcloud_mcp_server.server import (
1111
configure_calendar_tools,
12+
configure_contacts_tools,
1213
configure_notes_tools,
1314
configure_tables_tools,
1415
configure_webdav_tools,
@@ -56,6 +57,7 @@ async def nc_get_capabilities():
5657
configure_tables_tools(mcp)
5758
configure_webdav_tools(mcp)
5859
configure_calendar_tools(mcp)
60+
configure_contacts_tools(mcp)
5961

6062

6163
def run():

nextcloud_mcp_server/client/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from ..controllers.notes_search import NotesSearchController
77
from .calendar import CalendarClient
8+
from .contacts import ContactsClient
89
from .notes import NotesClient
910
from .tables import TablesClient
1011
from .webdav import WebDAVClient
@@ -43,6 +44,7 @@ def __init__(self, base_url: str, username: str, auth: Auth | None = None):
4344
self.webdav = WebDAVClient(self._client, username)
4445
self.tables = TablesClient(self._client, username)
4546
self.calendar = CalendarClient(self._client, username)
47+
self.contacts = ContactsClient(self._client, username)
4648

4749
# Initialize controllers
4850
self._notes_search = NotesSearchController()
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
"""CardDAV client for NextCloud contacts operations."""
2+
3+
import logging
4+
from .base import BaseNextcloudClient
5+
import xml.etree.ElementTree as ET
6+
from pythonvCard4.vcard import Contact
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class ContactsClient(BaseNextcloudClient):
12+
"""Client for NextCloud CardDAV contact operations."""
13+
14+
def _get_carddav_base_path(self) -> str:
15+
"""Helper to get the base CardDAV path for contacts."""
16+
return f"/remote.php/dav/addressbooks/users/{self.username}"
17+
18+
async def list_addressbooks(self):
19+
"""List all available addressbooks for the user."""
20+
21+
carddav_path = self._get_carddav_base_path()
22+
23+
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
24+
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
25+
<d:prop>
26+
<d:displayname/>
27+
<d:getctag />
28+
</d:prop>
29+
</d:propfind>"""
30+
31+
headers = {
32+
# "Depth": "0",
33+
"Content-Type": "application/xml",
34+
"Accept": "application/xml",
35+
}
36+
37+
response = await self._make_request(
38+
"PROPFIND", carddav_path, content=propfind_body, headers=headers
39+
)
40+
41+
ns = {"d": "DAV:"}
42+
43+
# logger.info(response.content)
44+
root = ET.fromstring(response.content)
45+
addressbooks = []
46+
for response_elem in root.findall(".//d:response", ns):
47+
href = response_elem.find(".//d:href", ns)
48+
if href is None:
49+
continue
50+
51+
href_text = href.text or ""
52+
if not href_text.endswith("/"):
53+
continue # Skip non-addressbook resources
54+
55+
# Extract addressbook name from href
56+
addressbook_name = href_text.rstrip("/").split("/")[-1]
57+
if not addressbook_name or addressbook_name == self.username:
58+
continue
59+
60+
# Get properties
61+
propstat = response_elem.find(".//d:propstat", ns)
62+
if propstat is None:
63+
continue
64+
65+
prop = propstat.find(".//d:prop", ns)
66+
if prop is None:
67+
continue
68+
69+
displayname_elem = prop.find(".//d:displayname", ns)
70+
displayname = (
71+
displayname_elem.text
72+
if displayname_elem is not None
73+
else addressbook_name
74+
)
75+
76+
getctag_elem = prop.find(".//d:getctag", ns)
77+
getctag = getctag_elem.text if getctag_elem is not None else None
78+
79+
addressbooks.append(
80+
{
81+
"name": addressbook_name,
82+
"display_name": displayname,
83+
"getctag": getctag,
84+
}
85+
)
86+
87+
logger.debug(f"Found {len(addressbooks)} addressbooks")
88+
return addressbooks
89+
90+
async def create_addressbook(self, *, name: str, display_name: str):
91+
"""Create a new addressbook."""
92+
carddav_path = self._get_carddav_base_path()
93+
url = f"{carddav_path}/{name}/"
94+
95+
prop_body = f"""<?xml version="1.0" encoding="utf-8"?>
96+
<d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
97+
<d:set>
98+
<d:prop>
99+
<d:resourcetype>
100+
<d:collection/>
101+
<c:addressbook/>
102+
</d:resourcetype>
103+
<d:displayname>{display_name}</d:displayname>
104+
</d:prop>
105+
</d:set>
106+
</d:mkcol>"""
107+
108+
headers = {
109+
"Content-Type": "application/xml",
110+
}
111+
112+
await self._make_request("MKCOL", url, content=prop_body, headers=headers)
113+
114+
async def delete_addressbook(self, *, name: str):
115+
"""Delete an addressbook."""
116+
carddav_path = self._get_carddav_base_path()
117+
url = f"{carddav_path}/{name}/"
118+
await self._make_request("DELETE", url)
119+
120+
async def create_contact(self, *, addressbook: str, uid: str, contact_data: dict):
121+
"""Create a new contact."""
122+
carddav_path = self._get_carddav_base_path()
123+
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
124+
125+
contact = Contact(fn=contact_data.get("fn"), uid=uid)
126+
if "email" in contact_data:
127+
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
128+
if "tel" in contact_data:
129+
contact.tel = [{"value": contact_data["tel"], "type": ["HOME"]}]
130+
131+
vcard = contact.to_vcard()
132+
133+
headers = {
134+
"Content-Type": "text/vcard; charset=utf-8",
135+
"If-None-Match": "*",
136+
}
137+
138+
await self._make_request("PUT", url, content=vcard, headers=headers)
139+
140+
async def delete_contact(self, *, addressbook: str, uid: str):
141+
"""Delete a contact."""
142+
carddav_path = self._get_carddav_base_path()
143+
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
144+
await self._make_request("DELETE", url)
145+
146+
async def list_contacts(self, *, addressbook: str):
147+
"""List all available contacts for addressbook."""
148+
149+
carddav_path = self._get_carddav_base_path()
150+
151+
report_body = """<?xml version="1.0" encoding="utf-8"?>
152+
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
153+
<d:prop>
154+
<d:getetag />
155+
<card:address-data />
156+
</d:prop>
157+
</card:addressbook-query>"""
158+
159+
headers = {
160+
"Depth": "1",
161+
"Content-Type": "application/xml",
162+
"Accept": "application/xml",
163+
}
164+
165+
response = await self._make_request(
166+
"REPORT",
167+
f"{carddav_path}/{addressbook}",
168+
content=report_body,
169+
headers=headers,
170+
)
171+
172+
ns = {"d": "DAV:", "card": "urn:ietf:params:xml:ns:carddav"}
173+
174+
# logger.info(response.text)
175+
root = ET.fromstring(response.content)
176+
contacts = []
177+
for response_elem in root.findall(".//d:response", ns):
178+
href = response_elem.find(".//d:href", ns)
179+
if href is None:
180+
logger.info("Skip missing href")
181+
continue
182+
183+
href_text = href.text or ""
184+
# logger.info("Href text: %s", href_text)
185+
# if not href_text.endswith("/"):
186+
# logger.info("# Skip non-addressbook resources")
187+
# continue
188+
189+
# Extract vcard id from href
190+
vcard_id = href_text.rstrip("/").split("/")[-1]
191+
if not vcard_id:
192+
logger.info("Skip missing vcard_id")
193+
continue
194+
vcard_id = vcard_id.replace(".vcf", "")
195+
196+
# Get properties
197+
propstat = response_elem.find(".//d:propstat", ns)
198+
if propstat is None:
199+
logger.info("Skip missing propstat")
200+
continue
201+
202+
prop = propstat.find(".//d:prop", ns)
203+
if prop is None:
204+
logger.info("Skip missing prop")
205+
continue
206+
207+
getetag_elem = prop.find(".//d:getetag", ns)
208+
getetag = getetag_elem.text if getetag_elem is not None else None
209+
210+
addressdata_elem = prop.find(".//card:address-data", ns)
211+
addressdata = (
212+
addressdata_elem.text if addressdata_elem is not None else None
213+
)
214+
if addressdata is None:
215+
logger.info("Skip missing addressdata")
216+
continue
217+
218+
contact = Contact.from_vcard(addressdata)
219+
220+
contacts.append(
221+
{
222+
"vcard_id": vcard_id,
223+
"getetag": getetag,
224+
"contact": {
225+
"fullname": contact.fn,
226+
"nickname": contact.nickname,
227+
"birthday": contact.bday,
228+
"email": contact.email,
229+
},
230+
"addressdata": addressdata,
231+
}
232+
)
233+
234+
logger.debug(f"Found {len(contacts)} contacts")
235+
return contacts

nextcloud_mcp_server/server/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
from .notes import configure_notes_tools
33
from .tables import configure_tables_tools
44
from .webdav import configure_webdav_tools
5+
from .contacts import configure_contacts_tools
56

67
__all__ = [
78
"configure_calendar_tools",
89
"configure_notes_tools",
910
"configure_tables_tools",
1011
"configure_webdav_tools",
12+
"configure_contacts_tools",
1113
]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import logging
2+
3+
from mcp.server.fastmcp import Context, FastMCP
4+
5+
from nextcloud_mcp_server.client import NextcloudClient
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
def configure_contacts_tools(mcp: FastMCP):
11+
# Contacts tools
12+
@mcp.tool()
13+
async def nc_contacts_list_addressbooks(ctx: Context):
14+
"""List all addressbooks for the user."""
15+
client: NextcloudClient = ctx.request_context.lifespan_context.client
16+
return await client.contacts.list_addressbooks()
17+
18+
@mcp.tool()
19+
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
20+
"""List all addressbooks for the user."""
21+
client: NextcloudClient = ctx.request_context.lifespan_context.client
22+
return await client.contacts.list_contacts(addressbook=addressbook)
23+
24+
@mcp.tool()
25+
async def nc_contacts_create_addressbook(
26+
ctx: Context, *, name: str, display_name: str
27+
):
28+
"""Create a new addressbook.
29+
30+
Args:
31+
name: The name of the addressbook.
32+
display_name: The display name of the addressbook.
33+
"""
34+
client: NextcloudClient = ctx.request_context.lifespan_context.client
35+
return await client.contacts.create_addressbook(
36+
name=name, display_name=display_name
37+
)
38+
39+
@mcp.tool()
40+
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
41+
"""Delete an addressbook."""
42+
client: NextcloudClient = ctx.request_context.lifespan_context.client
43+
return await client.contacts.delete_addressbook(name=name)
44+
45+
@mcp.tool()
46+
async def nc_contacts_create_contact(
47+
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
48+
):
49+
"""Create a new contact.
50+
51+
Args:
52+
addressbook: The name of the addressbook to create the contact in.
53+
uid: The unique ID for the contact.
54+
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "[email protected]"}.
55+
"""
56+
client: NextcloudClient = ctx.request_context.lifespan_context.client
57+
return await client.contacts.create_contact(
58+
addressbook=addressbook, uid=uid, contact_data=contact_data
59+
)
60+
61+
@mcp.tool()
62+
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
63+
"""Delete a contact."""
64+
client: NextcloudClient = ctx.request_context.lifespan_context.client
65+
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ dependencies = [
1111
"mcp[cli] (>=1.10,<1.11)",
1212
"httpx (>=0.28.1,<0.29.0)",
1313
"pillow (>=11.2.1,<12.0.0)",
14-
"icalendar (>=6.0.0,<7.0.0)"
14+
"icalendar (>=6.0.0,<7.0.0)",
15+
"pythonvcard4>=0.2.0",
1516
]
1617

1718
[tool.pytest.ini_options]

0 commit comments

Comments
 (0)