Skip to content

Commit 70f01bf

Browse files
author
Chris Coutinho
committed
Add files
1 parent 37b1057 commit 70f01bf

File tree

4 files changed

+227
-0
lines changed

4 files changed

+227
-0
lines changed
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

main.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import asyncio
2+
import logging
3+
4+
from nextcloud_mcp_server.client import NextcloudClient
5+
6+
logging.basicConfig(level="INFO")
7+
logger = logging.getLogger(__name__)
8+
9+
client = NextcloudClient.from_env()
10+
11+
12+
async def main():
13+
addressbooks = await client.contacts.list_addressbooks()
14+
# print(addressbooks)
15+
16+
for addressbook in addressbooks:
17+
contacts = await client.contacts.list_contacts(addressbook=addressbook["name"])
18+
for contact in contacts:
19+
logger.info(
20+
"Contact etag: %s, details: %s", contact["getetag"], contact["contact"]
21+
)
22+
23+
24+
if __name__ == "__main__":
25+
asyncio.run(main())
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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 list_contacts(self, *, addressbook: str):
91+
"""List all available contacts for addressbook."""
92+
93+
carddav_path = self._get_carddav_base_path()
94+
95+
report_body = """<?xml version="1.0" encoding="utf-8"?>
96+
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
97+
<d:prop>
98+
<d:getetag />
99+
<card:address-data />
100+
</d:prop>
101+
</card:addressbook-query>"""
102+
103+
headers = {
104+
"Depth": "1",
105+
"Content-Type": "application/xml",
106+
"Accept": "application/xml",
107+
}
108+
109+
response = await self._make_request(
110+
"REPORT",
111+
f"{carddav_path}/{addressbook}",
112+
content=report_body,
113+
headers=headers,
114+
)
115+
116+
ns = {"d": "DAV:", "card": "urn:ietf:params:xml:ns:carddav"}
117+
118+
# logger.info(response.text)
119+
root = ET.fromstring(response.content)
120+
contacts = []
121+
for response_elem in root.findall(".//d:response", ns):
122+
href = response_elem.find(".//d:href", ns)
123+
if href is None:
124+
logger.info("Skip missing href")
125+
continue
126+
127+
href_text = href.text or ""
128+
# logger.info("Href text: %s", href_text)
129+
# if not href_text.endswith("/"):
130+
# logger.info("# Skip non-addressbook resources")
131+
# continue
132+
133+
# Extract vcard id from href
134+
vcard_id = href_text.rstrip("/").split("/")[-1]
135+
if not vcard_id:
136+
logger.info("Skip missing vcard_id")
137+
continue
138+
139+
# Get properties
140+
propstat = response_elem.find(".//d:propstat", ns)
141+
if propstat is None:
142+
logger.info("Skip missing propstat")
143+
continue
144+
145+
prop = propstat.find(".//d:prop", ns)
146+
if prop is None:
147+
logger.info("Skip missing prop")
148+
continue
149+
150+
getetag_elem = prop.find(".//d:getetag", ns)
151+
getetag = getetag_elem.text if getetag_elem is not None else None
152+
153+
addressdata_elem = prop.find(".//card:address-data", ns)
154+
addressdata = (
155+
addressdata_elem.text if addressdata_elem is not None else None
156+
)
157+
if addressdata is None:
158+
logger.info("Skip missing addressdata")
159+
continue
160+
161+
contact = Contact.from_vcard(addressdata)
162+
163+
contacts.append(
164+
{
165+
"getetag": getetag,
166+
"contact": {
167+
"fullname": contact.fn,
168+
"nickname": contact.nickname,
169+
"birthday": contact.bday,
170+
"email": contact.email,
171+
},
172+
"addressdata": addressdata,
173+
}
174+
)
175+
176+
logger.debug(f"Found {len(contacts)} contacts")
177+
return contacts
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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)

0 commit comments

Comments
 (0)