fix(contacts): surface ORG/TITLE/NOTE/URL/CATEGORIES/PHOTO on read (refs #716)

PR #719 fixed the contact-create path so all documented fields persist to the
vCard, but the read path (list/search via MCP) still returned ``organization:
null`` / ``note: null`` / ``title: null`` because pythonvCard4 has no typed
parser for ORG/TITLE — they land in ``Contact.custom`` — and the server-side
mapper never read ``note``/``urls``/``categories``/``photo`` even when present.

Reads now surface what the write side persisted:

- ``client/contacts.py``: new ``_first_custom`` helper pulls raw values from
  ``Contact.custom`` for ORG/TITLE/unencoded PHOTO. ``list_contacts``
  extends its per-contact dict with org/title/note/url/categories/photo.
- ``server/contacts.py``: ``_raw_contact_to_model`` maps the new keys onto
  ``Contact.organization`` / ``.title`` / ``.note`` / ``.urls`` / ``.categories``
  / ``.photo``. URL accepts both list and plain-string shapes; categories
  accepts comma-separated strings for forward-compat.

Coverage:

- Unit: ``TestFirstCustom`` (five cases incl. bare-string library shape) and
  three new ``_raw_contact_to_model`` cases covering the full field set,
  plain-string URL, and comma-string categories.
- Integration: ``test_mcp_contacts_workflow`` now decodes the
  ``nc_contacts_search_contacts`` response and asserts
  ``organization`` / ``note`` round-trip — direct regression coverage for
  elvisdragonmao's report on issue #716.

Verified end-to-end against the local single-user docker stack: creating a
contact with ``{organization, title, note, url, categories}`` and reading it
back via ``nc_contacts_search_contacts`` returns every field populated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-21 10:07:47 +02:00
co-authored by Claude Opus 4.7
parent 04a6294cc5
commit 688a03f00a
5 changed files with 179 additions and 3 deletions
+29 -3
View File
@@ -65,15 +65,35 @@ def _parse_vcard_fields(
def _raw_contact_to_model(raw: dict) -> Contact:
"""Convert a raw contact dict from the contacts client to a Contact model.
Maps fullname, nickname, birthday, email, and tel fields.
Email/tel values may be plain strings, dicts with ``value``/``type`` keys,
or lists of either see :func:`_parse_vcard_fields`.
Maps fullname, nickname, birthday, email, tel, org, title, note, url,
categories, and photo fields. Email/tel values may be plain strings, dicts
with ``value``/``type`` keys, or lists of either see
:func:`_parse_vcard_fields`.
"""
contact_info = raw.get("contact", {})
emails = _parse_vcard_fields(contact_info.get("email"), "email")
phones = _parse_vcard_fields(contact_info.get("tel"), "phone")
# URL is parsed by pythonvCard4 into a plain ``list[str]``. Single-string
# inputs surface as such too. Either way wrap each into a ContactField.
raw_urls = contact_info.get("url")
if isinstance(raw_urls, str):
raw_urls = [raw_urls] if raw_urls else []
urls = [
ContactField(type="url", value=u)
for u in (raw_urls or [])
if isinstance(u, str) and u
]
# CATEGORIES is parsed as ``list[str]``. Accept a comma-separated string
# too for forward-compat with library updates that might change shape.
raw_categories = contact_info.get("categories") or []
if isinstance(raw_categories, str):
categories = [c.strip() for c in raw_categories.split(",") if c.strip()]
else:
categories = [c for c in raw_categories if isinstance(c, str) and c]
# Nickname goes into custom_fields (no dedicated model field)
custom_fields: dict[str, Any] = {}
nickname = contact_info.get("nickname")
@@ -84,11 +104,17 @@ def _raw_contact_to_model(raw: dict) -> Contact:
uid=raw["vcard_id"],
fn=contact_info.get("fullname", ""),
etag=raw.get("getetag"),
organization=contact_info.get("org"),
title=contact_info.get("title"),
note=contact_info.get("note"),
photo=contact_info.get("photo"),
birthday=contact_info["birthday"].isoformat()
if isinstance(contact_info.get("birthday"), date)
else contact_info.get("birthday"),
emails=emails,
phones=phones,
urls=urls,
categories=categories,
custom_fields=custom_fields,
)