From 7974b67d4bca9a36b4926db8b5ec51beed63f322 Mon Sep 17 00:00:00 2001 From: KuriGohan-Kamehameha <16231581+KuriGohan-Kamehameha@users.noreply.github.com> Date: Thu, 7 May 2026 21:52:30 -0400 Subject: [PATCH] feat(contacts): add nc_contacts_search_contacts free-text search tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a search tool that finds contacts by free-text substring match across the four fields users actually look at: - full name (FN) - nickname - any email address - any phone number (compared as digits-only so "+1 234-567-890" matches a search for "2345678") Without this tool, an MCP client that wants to "find John's email" has to pull every contact via list_contacts and filter client-side, which costs a CardDAV REPORT per addressbook even when the user only has a single hit. Doing the filter server-side (still cheap — it streams the full vcards but discards non-matches before serialising the response) keeps the tool surface symmetric with the rest of the contacts API: list, get, create, update, delete, *search*. When ``addressbook`` is omitted the search spans every addressbook the authenticated user can read. License: AGPL-3.0, matching the project. --- nextcloud_mcp_server/server/contacts.py | 78 +++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/nextcloud_mcp_server/server/contacts.py b/nextcloud_mcp_server/server/contacts.py index 2becd064..f495f6fd 100644 --- a/nextcloud_mcp_server/server/contacts.py +++ b/nextcloud_mcp_server/server/contacts.py @@ -142,6 +142,84 @@ def configure_contacts_tools(mcp: FastMCP): contacts=contacts, addressbook=addressbook, total_count=len(contacts) ) + @mcp.tool( + title="Search Contacts", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) + @require_scopes("contacts.read") + @instrument_tool + async def nc_contacts_search_contacts( + ctx: Context, *, query: str, addressbook: str | None = None + ) -> ListContactsResponse: + """Search contacts by free-text query across name, nickname, email, and phone. + + The query is matched case-insensitively as a substring against: + - the contact's full name (FN) + - any nickname + - every email address + - every phone number (digits only — formatting is stripped before + comparison so '+1 234 567 890' matches '2345678' and '234.567.890') + + Args: + query: Free-text search string (case-insensitive substring match). + An empty query returns no results — use list_contacts for that. + addressbook: Optional URI slug of a specific addressbook to search. + When omitted, every addressbook for the user is searched. + + Returns: + ListContactsResponse with matching contacts. The ``addressbook`` + field is set to the searched addressbook, or ``"*"`` when all + addressbooks were searched. + """ + client = await get_client(ctx) + needle = (query or "").strip().lower() + if not needle: + return ListContactsResponse( + contacts=[], addressbook=addressbook or "*", total_count=0 + ) + + # Phone numbers are normalised to digits-only for comparison so that + # users can search for "2345678" and find "+1 234-567-8" etc. + digits_needle = "".join(ch for ch in needle if ch.isdigit()) + + if addressbook: + address_books = [addressbook] + else: + address_books = [ + ab["name"] for ab in await client.contacts.list_addressbooks() + ] + + matches: list[Contact] = [] + for ab_slug in address_books: + raw_contacts = await client.contacts.list_contacts(addressbook=ab_slug) + for raw in raw_contacts: + contact = _raw_contact_to_model(raw) + hay_parts: list[str] = [] + if contact.fn: + hay_parts.append(contact.fn.lower()) + nickname = contact.custom_fields.get("nickname") if contact.custom_fields else None + if nickname: + hay_parts.append(str(nickname).lower()) + for e in contact.emails: + hay_parts.append(e.value.lower()) + hay = " ".join(hay_parts) + + phone_digits = "".join( + "".join(ch for ch in p.value if ch.isdigit()) + for p in contact.phones + ) + + if needle in hay: + matches.append(contact) + elif digits_needle and digits_needle in phone_digits: + matches.append(contact) + + return ListContactsResponse( + contacts=matches, + addressbook=addressbook or "*", + total_count=len(matches), + ) + @mcp.tool( title="Create Address Book", annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),