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 1/4] 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), From 4774af1b69bafc39e3320e46581e983235479978 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 16:21:32 +0000 Subject: [PATCH 2/4] chore(deps): update nextcloud-32:32.0.9 docker digest to 6052173 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f85eca78..c343cb04 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,7 @@ jobs: - nextcloud_version: "31" nextcloud_image: "docker.io/library/nextcloud:31.0.14@sha256:07ec73cc816e58d6f45a162cd53ef886462c29271a23fc68d0124cec276e3767" - nextcloud_version: "32" - nextcloud_image: "docker.io/library/nextcloud:32.0.9@sha256:fdca48a6025e63d7b52913cad8ddf341c37b9dd8d03c4962ea0a1ec0c42c7442" + nextcloud_image: "docker.io/library/nextcloud:32.0.9@sha256:6052173f5abdd4e37868262234b9fa651f70223d3bcd7c178c51e59101fdb733" # Disabled until all upstream apps support NC 33 # - nextcloud_version: "33" # nextcloud_image: "docker.io/library/nextcloud:33.0.3@sha256:76f04e434a82572dbfee7ad6dca313229eade89ee538fccd2bf6396f4440d48b" From 4b0a55675028b7c53cccf2ccec2988024bb0ca53 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 16:21:38 +0000 Subject: [PATCH 3/4] chore(deps): update nextcloud-33:33.0.3 docker digest to 90a730e --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f85eca78..a06529ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,7 +53,7 @@ jobs: nextcloud_image: "docker.io/library/nextcloud:32.0.9@sha256:fdca48a6025e63d7b52913cad8ddf341c37b9dd8d03c4962ea0a1ec0c42c7442" # Disabled until all upstream apps support NC 33 # - nextcloud_version: "33" - # nextcloud_image: "docker.io/library/nextcloud:33.0.3@sha256:76f04e434a82572dbfee7ad6dca313229eade89ee538fccd2bf6396f4440d48b" + # nextcloud_image: "docker.io/library/nextcloud:33.0.3@sha256:90a730e9a3dd290d9626ca64740c4d2fa901b4f231fe4ab6eb0dcf300dbc7271" # Mode-specific properties - mode: single-user From 9c62fb7ca96baaff393834169caae46c24944c75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 10 May 2026 16:00:46 +0000 Subject: [PATCH 4/4] =?UTF-8?q?bump:=20version=200.83.4=20=E2=86=92=200.84?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d17275..e2d0902c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the Nextcloud MCP Server will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [PEP 440](https://peps.python.org/pep-0440/). +## v0.84.0 (2026-05-10) + +### Feat + +- **contacts**: add nc_contacts_search_contacts free-text search tool + ## v0.83.4 (2026-05-10) ### Fix diff --git a/pyproject.toml b/pyproject.toml index f813b5ce..14ce7e77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.83.4" +version = "0.84.0" description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data" authors = [ {name = "Chris Coutinho", email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index abcf9bd9..11bca4f4 100644 --- a/uv.lock +++ b/uv.lock @@ -2123,7 +2123,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.83.4" +version = "0.84.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" },