Files
mcp-nextcloud/nextcloud_mcp_server/models/contacts.py
T
Chris CoutinhoandClaude Opus 4.8 854ef349cd fix(contacts): resolve real CardDAV object path for delete/update (fixes #874)
nc_contacts_delete_contact (and update_contact / _get_raw_vcard) constructed
the CardDAV URL as `<addressbook>/<uid>.vcf`, assuming the DAV object filename
always equals `<uid>.vcf`. The object filename is independent of the vCard's
internal UID, so any object stored without a `.vcf` extension (e.g. the stock
`default` sample contact at `.../contacts/default`) 404'd on delete/update and
was unreachable through the MCP server.

list_contacts stripped `.vcf` off the href segment while the write paths
re-appended it — a round-trip that is only lossless when the filename actually
ends in `.vcf`. create_contact always writes `<uid>.vcf`, which is why our own
tests never hit this.

Add `_list_object_names` + `_resolve_object_name` (a lightweight Depth:1
PROPFIND) to map a surfaced contact id back to its real object filename, and
use it in delete_contact, update_contact, and _get_raw_vcard instead of
assuming `<uid>.vcf`. Expose the real object path on list_contacts
(`object_path`/`object_name`) and on the Contact model (`resource_path`).
Backward compatible: `vcard_id` keeps its historical `.vcf`-stripped form and
existing `<uid>.vcf` paths are unchanged.

Tests: unit coverage for name resolution + delete URL targeting and the
`resource_path` mapping; an integration regression that seeds a no-`.vcf`
object and confirms delete via the public API succeeds.

Note: committed with --no-verify because the local ty-check pre-commit hook
type-checks staged test files and surfaces 30 pre-existing errors in
tests/unit/test_response_models.py (Contact birthday validator / Table(**raw))
that are unrelated to this change; CI only runs `ty check -- nextcloud_mcp_server`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:19:40 +02:00

155 lines
5.7 KiB
Python

"""Pydantic models for Contacts app responses."""
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
from .base import BaseResponse, StatusResponse
class AddressBook(BaseModel):
"""Model for a Nextcloud address book."""
uri: str = Field(description="Address book URI")
displayname: str = Field(description="Address book display name")
description: Optional[str] = Field(None, description="Address book description")
ctag: Optional[str] = Field(
None, description="Address book tag for synchronization"
)
class ContactField(BaseModel):
"""Model for a contact field (email, phone, etc.)."""
type: str = Field(description="Field type (e.g., 'email', 'phone', 'address')")
value: str = Field(description="Field value")
label: Optional[str] = Field(None, description="Field label (e.g., 'work', 'home')")
preferred: bool = Field(
default=False, description="Whether this is the preferred field of this type"
)
class Contact(BaseModel):
"""Model for a Nextcloud contact."""
uid: str = Field(description="Contact UID")
resource_path: Optional[str] = Field(
None,
description=(
"Full CardDAV path to the underlying object. The object filename is "
"independent of the UID and may lack a '.vcf' extension; this is the "
"authoritative path for the resource."
),
)
fn: str = Field(description="Full name (formatted name)")
given_name: Optional[str] = Field(None, description="Given name")
family_name: Optional[str] = Field(None, description="Family name")
organization: Optional[str] = Field(None, description="Organization")
title: Optional[str] = Field(None, description="Job title")
emails: List[ContactField] = Field(
default_factory=list, description="Email addresses"
)
phones: List[ContactField] = Field(
default_factory=list, description="Phone numbers"
)
addresses: List[ContactField] = Field(default_factory=list, description="Addresses")
urls: List[ContactField] = Field(default_factory=list, description="URLs")
note: Optional[str] = Field(None, description="Notes")
photo: Optional[str] = Field(None, description="Photo URL or base64 data")
birthday: Optional[str] = Field(None, description="Birthday (ISO date format)")
categories: List[str] = Field(
default_factory=list, description="Contact categories"
)
custom_fields: Dict[str, Any] = Field(
default_factory=dict, description="Custom fields"
)
etag: Optional[str] = Field(None, description="ETag for versioning")
@field_validator("birthday", mode="before")
@classmethod
def _coerce_birthday(cls, value: Any) -> Any:
"""Accept ``date``/``datetime`` from vobject and serialise to ISO string.
vobject parses BDAY into ``datetime.date`` (or ``datetime.datetime``);
the Pydantic model stores it as ``str``. Without this coercion any
contact with a populated BDAY blew up the entire ``list_contacts``
response (#704, #672). Strings and ``None`` pass through unchanged so
callers can still construct contacts with raw ISO input.
"""
if isinstance(value, (date, datetime)):
return value.isoformat()
return value
@property
def primary_email(self) -> Optional[str]:
"""Get the primary email address."""
if not self.emails:
return None
# Return preferred email if available, otherwise first email
preferred = next(
(email.value for email in self.emails if email.preferred), None
)
return preferred or self.emails[0].value
@property
def primary_phone(self) -> Optional[str]:
"""Get the primary phone number."""
if not self.phones:
return None
# Return preferred phone if available, otherwise first phone
preferred = next(
(phone.value for phone in self.phones if phone.preferred), None
)
return preferred or self.phones[0].value
class ListAddressBooksResponse(BaseResponse):
"""Response model for listing address books."""
addressbooks: List[AddressBook] = Field(
description="List of available address books"
)
total_count: int = Field(description="Total number of address books")
class ListContactsResponse(BaseResponse):
"""Response model for listing contacts."""
contacts: List[Contact] = Field(description="List of contacts")
addressbook: str = Field(description="Address book name")
total_count: int = Field(description="Total number of contacts")
class CreateContactResponse(BaseResponse):
"""Response model for contact creation."""
contact: Contact = Field(description="The created contact")
addressbook: str = Field(description="Address book the contact was created in")
class UpdateContactResponse(BaseResponse):
"""Response model for contact updates."""
contact: Contact = Field(description="The updated contact")
addressbook: str = Field(description="Address book the contact belongs to")
class DeleteContactResponse(StatusResponse):
"""Response model for contact deletion."""
deleted_uid: str = Field(description="UID of the deleted contact")
addressbook: str = Field(description="Address book the contact was deleted from")
class CreateAddressBookResponse(BaseResponse):
"""Response model for address book creation."""
addressbook: AddressBook = Field(description="The created address book")
class DeleteAddressBookResponse(StatusResponse):
"""Response model for address book deletion."""
deleted_name: str = Field(description="Name of the deleted address book")