Compare commits

..

85 Commits

Author SHA1 Message Date
github-actions[bot] 1176479ec1 bump: version 0.64.4 → 0.64.5 2026-03-03 08:33:47 +00:00
Chris Coutinho 0f8b1c6325 Merge pull request #602 from cbcoutinho/fix/contacts-vcard-dict-format-601
fix: handle pythonvCard4 dict-format fields and missing phones (#601)
2026-03-03 09:33:27 +01:00
Chris Coutinho fdb7b87baf fix: handle pythonvCard4 dict-format fields and missing phone numbers (#601)
Fix three related contacts bugs:
- Parse dict-format vCard fields ({value, type}) that pythonvCard4 returns,
  which previously crashed Pydantic validation expecting plain strings
- Include tel field in client output so phone numbers reach MCP tools
- Clarify addressbook parameter expects URI slug, not displayname

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:32:53 +01:00
github-actions[bot] 184415eca1 bump: version 0.57.92 → 0.57.93 2026-03-03 06:13:03 +00:00
Chris Coutinho 658fd7e138 Merge pull request #600 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.66
2026-03-03 07:12:48 +01:00
renovate-bot-cbcoutinho[bot] a5d2025797 chore(deps): update anthropics/claude-code-action action to v1.0.66 2026-03-02 17:17:24 +00:00
github-actions[bot] 08aaa85ab3 bump: version 0.57.91 → 0.57.92 2026-03-02 11:35:44 +00:00
Chris Coutinho ecab777efa Merge pull request #598 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.65
2026-03-02 12:35:28 +01:00
github-actions[bot] c960560716 bump: version 0.57.90 → 0.57.91 2026-03-02 11:33:46 +00:00
Chris Coutinho 023927afff Merge pull request #599 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.47.0
2026-03-02 12:33:28 +01:00
renovate-bot-cbcoutinho[bot] 3a87b33288 chore(deps): update helm release ollama to v1.47.0 2026-03-02 11:15:34 +00:00
renovate-bot-cbcoutinho[bot] c8ebd9c089 chore(deps): update anthropics/claude-code-action action to v1.0.65 2026-03-02 11:15:16 +00:00
github-actions[bot] 5d81d60262 bump: version 0.57.89 → 0.57.90 2026-03-01 16:26:00 +00:00
Chris Coutinho b86e798ba8 Merge pull request #592 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.64
2026-03-01 17:25:47 +01:00
Chris Coutinho a7d623733b Merge pull request #591 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to f3fa41d
2026-03-01 17:25:35 +01:00
github-actions[bot] 3311b20ef6 bump: version 0.57.88 → 0.57.89 2026-03-01 16:24:44 +00:00
Chris Coutinho 28c7f1cdbd Merge pull request #594 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.7
2026-03-01 17:24:30 +01:00
github-actions[bot] 2713f74be6 bump: version 0.57.87 → 0.57.88 2026-03-01 16:23:38 +00:00
Chris Coutinho e3c5a87b22 Merge pull request #590 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.6
chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to 5c4e09f
2026-03-01 17:23:22 +01:00
Chris Coutinho 53cf223a56 Merge pull request #595 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.46.0
2026-03-01 17:23:11 +01:00
Chris Coutinho 6bfde0de1f Merge pull request #596 from cbcoutinho/renovate/major-github-artifact-actions
chore(deps): update actions/upload-artifact action to v7
2026-03-01 17:22:58 +01:00
github-actions[bot] 8cf3264914 bump: version 0.57.86 → 0.57.87 2026-03-01 16:05:13 +00:00
Chris Coutinho ed2f400ed8 Merge pull request #593 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.3.1
2026-03-01 17:04:56 +01:00
renovate-bot-cbcoutinho[bot] 6ba598afd1 chore(deps): update actions/upload-artifact action to v7 2026-03-01 16:03:54 +00:00
renovate-bot-cbcoutinho[bot] d0bfecea97 chore(deps): update helm release ollama to v1.46.0 2026-03-01 16:03:49 +00:00
renovate-bot-cbcoutinho[bot] bf0a4ac5d3 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.7 2026-03-01 16:03:28 +00:00
renovate-bot-cbcoutinho[bot] 3da6feba41 chore(deps): update astral-sh/setup-uv action to v7.3.1 2026-03-01 16:03:20 +00:00
renovate-bot-cbcoutinho[bot] 1224090469 chore(deps): update anthropics/claude-code-action action to v1.0.64 2026-03-01 16:03:13 +00:00
renovate-bot-cbcoutinho[bot] aa624401c3 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to f3fa41d 2026-03-01 16:03:08 +00:00
renovate-bot-cbcoutinho[bot] 61e867397c chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to 5c4e09f 2026-03-01 16:03:02 +00:00
github-actions[bot] 5796e2ba54 bump: version 0.57.85 → 0.57.86 2026-02-26 15:42:35 +00:00
github-actions[bot] 37141ea79f bump: version 0.64.3 → 0.64.4 2026-02-26 15:42:35 +00:00
Chris Coutinho 68126f6fe3 Merge pull request #555 from cbcoutinho/renovate/icalendar-7.x
fix(deps): update dependency icalendar to v7
2026-02-26 16:42:12 +01:00
github-actions[bot] 78b934ffa6 bump: version 0.57.84 → 0.57.85 2026-02-25 12:43:46 +00:00
Chris Coutinho 01a9ad5278 Merge pull request #588 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.60
2026-02-25 13:43:30 +01:00
github-actions[bot] b67a566902 bump: version 0.57.83 → 0.57.84 2026-02-25 11:35:38 +00:00
Chris Coutinho c9e8a56355 Merge pull request #584 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.6
chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to dcf9c60
2026-02-25 12:35:22 +01:00
renovate-bot-cbcoutinho[bot] 785ba5bf09 chore(deps): update anthropics/claude-code-action action to v1.0.60 2026-02-25 11:18:33 +00:00
renovate-bot-cbcoutinho[bot] 159ffb6110 chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to dcf9c60 2026-02-25 11:18:25 +00:00
github-actions[bot] 70139c4782 bump: version 0.57.82 → 0.57.83 2026-02-25 09:14:58 +00:00
Chris Coutinho a922187489 Merge pull request #586 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.59
2026-02-25 10:14:43 +01:00
github-actions[bot] 1ba6a142f5 bump: version 0.57.81 → 0.57.82 2026-02-25 08:42:21 +00:00
Chris Coutinho 79478f2483 Merge pull request #585 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 39e4e1c
2026-02-25 09:42:03 +01:00
github-actions[bot] 4721a5da52 bump: version 0.57.80 → 0.57.81 2026-02-25 07:26:59 +00:00
Chris Coutinho be2b683604 Merge pull request #587 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.6
2026-02-25 08:26:45 +01:00
renovate-bot-cbcoutinho[bot] 9fd3d92a0f chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.6 2026-02-25 05:15:47 +00:00
renovate-bot-cbcoutinho[bot] ceebda071f chore(deps): update anthropics/claude-code-action action to v1.0.59 2026-02-25 05:15:42 +00:00
renovate-bot-cbcoutinho[bot] 26fc48dc46 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 39e4e1c 2026-02-25 05:15:37 +00:00
renovate-bot-cbcoutinho[bot] 3edc226d17 fix(deps): update dependency icalendar to v7 2026-02-24 17:17:49 +00:00
github-actions[bot] 7384b47795 bump: version 0.57.79 → 0.57.80 2026-02-24 12:44:15 +00:00
Chris Coutinho b62d275dc9 Merge pull request #583 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.57
2026-02-24 13:43:57 +01:00
renovate-bot-cbcoutinho[bot] a0fa0230ab chore(deps): update anthropics/claude-code-action action to v1.0.57 2026-02-24 11:15:33 +00:00
github-actions[bot] 7314097483 bump: version 0.57.78 → 0.57.79 2026-02-24 09:34:47 +00:00
Chris Coutinho 3d070f74c5 Merge pull request #581 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.5
2026-02-24 10:34:30 +01:00
github-actions[bot] 80366a4e1e bump: version 0.57.77 → 0.57.78 2026-02-24 08:21:58 +00:00
Chris Coutinho 91941a9ece Merge pull request #582 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.56
2026-02-24 09:21:39 +01:00
github-actions[bot] 8fd6f4158f bump: version 0.57.76 → 0.57.77 2026-02-24 08:19:59 +00:00
Chris Coutinho b8e6539b6f Merge pull request #580 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to ba6cb07
2026-02-24 09:19:15 +01:00
github-actions[bot] fe53e93fe9 bump: version 0.57.75 → 0.57.76 2026-02-24 07:51:52 +00:00
Chris Coutinho 71d4c44b05 Merge pull request #579 from cbcoutinho/renovate/docker.io-library-redis-alpine
chore(deps): update docker.io/library/redis:alpine docker digest to 2afba59
2026-02-24 08:51:37 +01:00
renovate-bot-cbcoutinho[bot] 8261048741 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.5 2026-02-24 05:14:52 +00:00
renovate-bot-cbcoutinho[bot] 6443aca743 chore(deps): update anthropics/claude-code-action action to v1.0.56 2026-02-24 05:14:46 +00:00
renovate-bot-cbcoutinho[bot] a1b5e676e9 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to ba6cb07 2026-02-23 23:14:33 +00:00
renovate-bot-cbcoutinho[bot] 1d9168f614 chore(deps): update docker.io/library/redis:alpine docker digest to 2afba59 2026-02-23 23:14:28 +00:00
github-actions[bot] 9229440a58 bump: version 0.57.74 → 0.57.75 2026-02-23 06:08:54 +00:00
Chris Coutinho e507f29e83 Merge pull request #578 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.45.0
2026-02-23 07:08:40 +01:00
renovate-bot-cbcoutinho[bot] 5ac6d8d396 chore(deps): update helm release ollama to v1.45.0 2026-02-23 05:14:16 +00:00
github-actions[bot] ab71003c5d bump: version 0.57.73 → 0.57.74 2026-02-21 08:53:06 +00:00
Chris Coutinho 726b71eea1 Merge pull request #575 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.4
2026-02-21 09:52:51 +01:00
github-actions[bot] 3e50924169 bump: version 0.57.72 → 0.57.73 2026-02-21 08:38:08 +00:00
github-actions[bot] b2773317ef bump: version 0.64.2 → 0.64.3 2026-02-21 08:38:07 +00:00
Chris Coutinho dce3ca9a70 Merge pull request #574 from cbcoutinho/fix/wrap-list-returns-in-response-models
fix: wrap raw list returns in response models (#568)
2026-02-21 09:37:48 +01:00
Chris Coutinho 18e5baf2a5 fix: address PR #574 fourth review round
- Use lowercase generics (list[...]) in new deck response models
- Add clarifying comment on AddressBook.uri slug semantics
- Fall back calendar_display_name to calendar_name when absent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 09:36:14 +01:00
github-actions[bot] 24bc29ea64 bump: version 0.57.71 → 0.57.72 2026-02-20 18:23:30 +00:00
Chris Coutinho 44e7e2e09b Merge pull request #577 from cbcoutinho/renovate/qdrant-1.x
chore(deps): update helm release qdrant to v1.17.0
2026-02-20 19:23:12 +01:00
renovate-bot-cbcoutinho[bot] bcc0bfee8d chore(deps): update helm release qdrant to v1.17.0 2026-02-20 17:15:57 +00:00
github-actions[bot] 0f31d16158 bump: version 0.57.70 → 0.57.71 2026-02-20 13:33:00 +00:00
Chris Coutinho 7c0b84d398 Merge pull request #576 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.44.0
2026-02-20 14:32:44 +01:00
Chris Coutinho f51b27ba19 fix: address PR #574 third review round
- Guard board.labels against None in deck_get_labels and resource
- Add TODO comments for calendar_display_name in single-calendar paths
- Document _raw_contact_to_model scope limitation (maps only what the
  client returns; expanding requires changes to vCard parsing)
- Log debug warning when event has no start_datetime
- Verified Table model is safe with extra fields (Pydantic v2 ignores)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:49:55 +01:00
renovate-bot-cbcoutinho[bot] 010eb40d5c chore(deps): update helm release ollama to v1.44.0 2026-02-20 11:15:26 +00:00
renovate-bot-cbcoutinho[bot] 960d060d27 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.4 2026-02-20 11:15:05 +00:00
Chris Coutinho 76e6c12b56 fix: address PR #574 second review round
- Enrich single-calendar event dicts with calendar_name before mapping
  to CalendarEventSummary (list_events and upcoming_events paths)
- Extract _raw_contact_to_model() from inline mapping in contacts.py,
  fix custom_fields type annotation to dict[str, Any]
- Add unit tests for _event_dict_to_summary covering categories parsing,
  falsy coercion, and calendar name passthrough
- Replace duplicated test helper with import of production function

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:57:09 +01:00
Chris Coutinho 76e305006c fix: address PR #574 review comments
Restore contact email/birthday/nickname data and per-event calendar
source that were silently dropped during response model wrapping.
Remove dead elif branches in OAuth deck tests, add regression tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 09:39:46 +01:00
Chris Coutinho 8887aa241a fix: wrap raw list returns in response models to produce single TextContent block
MCP tools returning raw lists caused FastMCP's _convert_to_content() to create
one TextContent block per element. Most MCP clients only read content[0], so
they saw a single result instead of the full list.

Wrapped 9 tool functions in proper response objects:
- deck: deck_get_boards, deck_get_stacks, deck_get_cards, deck_get_labels
- calendar: nc_calendar_list_events, nc_calendar_get_upcoming_events
- contacts: nc_contacts_list_addressbooks, nc_contacts_list_contacts
- tables: nc_tables_list_tables

Closes #568

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 09:22:16 +01:00
github-actions[bot] 10d44edf4c bump: version 0.57.69 → 0.57.70 2026-02-20 07:15:37 +00:00
25 changed files with 753 additions and 105 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
uses: anthropics/claude-code-action@64c7a0ef71df67b14cb4471f4d9c8565c61042bf # v1.0.66
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: "renovate-bot-cbcoutinho"
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
uses: anthropics/claude-code-action@64c7a0ef71df67b14cb4471f4d9c8565c61042bf # v1.0.66
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
VECTOR_SYNC_SCAN_INTERVAL: "5"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
- name: Wait for Nextcloud to be ready
run: |
@@ -97,7 +97,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: rag-evaluation-results
path: |
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install the latest version of uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -56,7 +56,7 @@ jobs:
up-flags: "--build"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
- name: Install Playwright dependencies
run: |
+22
View File
@@ -5,6 +5,28 @@ 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.64.5 (2026-03-03)
### Fix
- handle pythonvCard4 dict-format fields and missing phone numbers (#601)
## v0.64.4 (2026-02-26)
### Fix
- **deps**: update dependency icalendar to v7
## v0.64.3 (2026-02-21)
### Fix
- address PR #574 fourth review round
- address PR #574 third review round
- address PR #574 second review round
- address PR #574 review comments
- wrap raw list returns in response models to produce single TextContent block
## v0.64.2 (2026-02-20)
### Fix
+2 -2
View File
@@ -1,6 +1,6 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
FROM docker.io/library/python:3.12-slim-trixie@sha256:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
COPY --from=ghcr.io/astral-sh/uv:0.10.4@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+2 -2
View File
@@ -12,12 +12,12 @@
# - Per-session app password authentication
# - Multi-user support via Smithery session config
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
FROM docker.io/library/python:3.12-slim-trixie@sha256:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
WORKDIR /app
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:0.10.4@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.57.69"
version = "0.57.93"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
+69
View File
@@ -14,6 +14,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable resource limits
- Grafana dashboard annotations
## nextcloud-mcp-server-0.57.93 (2026-03-03)
## nextcloud-mcp-server-0.57.92 (2026-03-02)
## nextcloud-mcp-server-0.57.91 (2026-03-02)
## nextcloud-mcp-server-0.57.90 (2026-03-01)
## nextcloud-mcp-server-0.57.89 (2026-03-01)
## nextcloud-mcp-server-0.57.88 (2026-03-01)
## nextcloud-mcp-server-0.57.87 (2026-03-01)
## nextcloud-mcp-server-0.57.86 (2026-02-26)
### Fix
- **deps**: update dependency icalendar to v7
## nextcloud-mcp-server-0.57.85 (2026-02-25)
## nextcloud-mcp-server-0.57.84 (2026-02-25)
## nextcloud-mcp-server-0.57.83 (2026-02-25)
## nextcloud-mcp-server-0.57.82 (2026-02-25)
## nextcloud-mcp-server-0.57.81 (2026-02-25)
## nextcloud-mcp-server-0.57.80 (2026-02-24)
## nextcloud-mcp-server-0.57.79 (2026-02-24)
## nextcloud-mcp-server-0.57.78 (2026-02-24)
## nextcloud-mcp-server-0.57.77 (2026-02-24)
## nextcloud-mcp-server-0.57.76 (2026-02-24)
## nextcloud-mcp-server-0.57.75 (2026-02-23)
## nextcloud-mcp-server-0.57.74 (2026-02-21)
## nextcloud-mcp-server-0.57.73 (2026-02-21)
### Fix
- address PR #574 fourth review round
- address PR #574 third review round
- address PR #574 second review round
- address PR #574 review comments
- wrap raw list returns in response models to produce single TextContent block
## nextcloud-mcp-server-0.57.72 (2026-02-20)
## nextcloud-mcp-server-0.57.71 (2026-02-20)
## nextcloud-mcp-server-0.57.70 (2026-02-20)
### Fix
- address PR #571 review comments
- resolve stale credentials causing astrolabe background sync test failures
### Refactor
- enforce PLC0415 (import-outside-top-level) for source code
## nextcloud-mcp-server-0.57.69 (2026-02-20)
## nextcloud-mcp-server-0.57.68 (2026-02-19)
+4 -4
View File
@@ -1,9 +1,9 @@
dependencies:
- name: qdrant
repository: https://qdrant.github.io/qdrant-helm
version: 1.16.3
version: 1.17.0
- name: ollama
repository: https://otwld.github.io/ollama-helm
version: 1.43.0
digest: sha256:533eda3fdb4bd92cdafee49bd7b0428fc87d21b509032442c04ed645900e464a
generated: "2026-02-16T11:16:41.257136832Z"
version: 1.47.0
digest: sha256:08d589dd1b3386e8e8a2ac2c03a2194218ab12ed9e02016e7b981e554385dd11
generated: "2026-03-02T11:15:27.688786078Z"
+4 -4
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application
version: 0.57.69
appVersion: "0.64.2"
version: 0.57.93
appVersion: "0.64.5"
keywords:
- nextcloud
- mcp
@@ -27,10 +27,10 @@ annotations:
grafana_dashboard_folder: "Nextcloud MCP"
dependencies:
- name: qdrant
version: "1.16.3"
version: "1.17.0"
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
version: "1.43.0"
version: "1.47.0"
repository: https://otwld.github.io/ollama-helm
condition: ollama.enabled
+4 -4
View File
@@ -19,11 +19,11 @@ services:
# Note: Redis is an external service. You can find more information about the configuration here:
# https://hub.docker.com/_/redis
redis:
image: docker.io/library/redis:alpine@sha256:fd83658b0e40e2164617d262f13c02ca9ee9e1e6b276fd2fa06617e09bd5c780
image: docker.io/library/redis:alpine@sha256:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0
restart: always
app:
image: docker.io/library/nextcloud:32.0.6@sha256:0e1084cc59df77bec7d6bb29d9ac6939da8372512237a9c51f74ff0a970524f2
image: docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267
restart: always
ports:
- 127.0.0.1:8080:80
@@ -60,7 +60,7 @@ services:
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
unstructured:
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:3b9280eb9aa53d76a8f4a2465400ae747774d4bfd71dd73d603353b0b55c435d
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:ba6cb073af079c498e9466a5a9152ba4b6c9cad12efeeaf053ba383023d5db08
restart: always
ports:
- 127.0.0.1:8002:8000
@@ -207,7 +207,7 @@ services:
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.5.3@sha256:5a236ae4dd8ece77490115bace15a11a4d15e9cbcf58a490b95a7da2cd71d32a
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
command:
- "start-dev"
- "--import-realm"
+1
View File
@@ -274,6 +274,7 @@ class ContactsClient(BaseNextcloudClient):
"nickname": contact.nickname,
"birthday": contact.bday,
"email": contact.email,
"tel": contact.tel,
},
"addressdata": addressdata,
}
+6
View File
@@ -34,6 +34,12 @@ class CalendarEventSummary(BaseModel):
status: Optional[str] = Field(
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
)
calendar_name: Optional[str] = Field(
None, description="Calendar containing this event"
)
calendar_display_name: Optional[str] = Field(
None, description="Display name of calendar containing this event"
)
class CalendarEvent(CalendarEventSummary):
+14
View File
@@ -261,6 +261,20 @@ class CreateLabelResponse(BaseResponse):
color: str = Field(description="The created label color")
class ListCardsResponse(BaseResponse):
"""Response model for listing deck cards."""
cards: list[DeckCard] = Field(description="List of deck cards")
total: int = Field(description="Total number of cards")
class ListLabelsResponse(BaseResponse):
"""Response model for listing deck labels."""
labels: list[DeckLabel] = Field(description="List of deck labels")
total: int = Field(description="Total number of labels")
class LabelOperationResponse(StatusResponse):
"""Response model for label operations like update/delete."""
+62 -8
View File
@@ -9,15 +9,46 @@ from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.calendar import (
Calendar,
CalendarEventSummary,
ListCalendarsResponse,
ListEventsResponse,
ListTodosResponse,
Todo,
UpcomingEventsResponse,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
"""Convert a raw event dict from the calendar client to a CalendarEventSummary."""
raw_categories = event.get("categories", [])
if isinstance(raw_categories, str):
categories = [c.strip() for c in raw_categories.split(",") if c.strip()]
else:
categories = raw_categories
start = event.get("start_datetime", "")
if not start:
logger.debug("Event %s has no start_datetime", event.get("uid", "unknown"))
return CalendarEventSummary(
uid=event.get("uid", ""),
summary=event.get("title", ""),
start=start,
end=event.get("end_datetime"),
all_day=event.get("all_day", False),
location=event.get("location") or None,
description=event.get("description") or None,
categories=categories,
status=event.get("status"),
calendar_name=event.get("calendar_name"),
calendar_display_name=event.get("calendar_display_name")
or event.get("calendar_name"),
)
def configure_calendar_tools(mcp: FastMCP):
# Calendar tools
@mcp.tool(
@@ -204,7 +235,7 @@ def configure_calendar_tools(mcp: FastMCP):
end_datetime=end_datetime,
filters=filters if filters else None,
)
return events[:limit]
events = events[:limit]
else:
# Search in specific calendar
events = await client.calendar.get_calendar_events(
@@ -214,11 +245,25 @@ def configure_calendar_tools(mcp: FastMCP):
limit=limit,
)
# Enrich events with calendar context for per-event mapping.
# Note: calendar_display_name is not available here without an
# extra list_calendars() call; the response-level calendar_name
# already identifies the calendar for single-calendar queries.
for event in events:
event["calendar_name"] = calendar_name
# Apply filters if provided
if filters:
events = client.calendar._apply_event_filters(events, filters)
return events
summaries = [_event_dict_to_summary(e) for e in events]
return ListEventsResponse(
events=summaries,
calendar_name=None if search_all_calendars else calendar_name,
start_date=start_date or None,
end_date=end_date or None,
total_found=len(summaries),
)
@mcp.tool(
title="Get Calendar Event",
@@ -420,12 +465,15 @@ def configure_calendar_tools(mcp: FastMCP):
if calendar_name:
# Get events from specific calendar
return await client.calendar.get_calendar_events(
events = await client.calendar.get_calendar_events(
calendar_name=calendar_name,
start_datetime=now,
end_datetime=end_datetime,
limit=limit,
)
# calendar_display_name not available without extra API call
for event in events:
event["calendar_name"] = calendar_name
else:
# Get events from all calendars
all_calendars = await client.calendar.list_calendars()
@@ -433,17 +481,16 @@ def configure_calendar_tools(mcp: FastMCP):
for calendar in all_calendars:
try:
events = await client.calendar.get_calendar_events(
cal_events = await client.calendar.get_calendar_events(
calendar_name=calendar["name"],
start_datetime=now,
end_datetime=end_datetime,
limit=limit,
)
# Add calendar info to each event
for event in events:
for event in cal_events:
event["calendar_name"] = calendar["name"]
event["calendar_display_name"] = calendar["display_name"]
all_events.extend(events)
all_events.extend(cal_events)
except Exception as e:
logger.warning(
f"Error getting events from calendar {calendar['name']}: {e}"
@@ -452,7 +499,14 @@ def configure_calendar_tools(mcp: FastMCP):
# Sort by start time and limit
all_events.sort(key=lambda x: x.get("start_datetime", ""))
return all_events[:limit]
events = all_events[:limit]
summaries = [_event_dict_to_summary(e) for e in events]
return UpcomingEventsResponse(
events=summaries,
days_ahead=days_ahead,
calendar_name=calendar_name or None,
)
@mcp.tool(
title="Find Availability",
+124 -8
View File
@@ -1,15 +1,95 @@
import logging
from typing import Any
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.contacts import (
AddressBook,
Contact,
ContactField,
ListAddressBooksResponse,
ListContactsResponse,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
def _parse_vcard_fields(
raw_values: str | dict | list | None, field_type: str
) -> list[ContactField]:
"""Parse polymorphic vCard field data into a list of ContactField.
pythonvCard4 returns field values in several shapes:
- ``str`` plain value, e.g. ``"alice@example.com"``
- ``dict`` ``{'value': '...', 'type': ['HOME', 'PREF']}``
- ``list`` a list whose items are any of the above
The ``PREF`` type parameter is treated as a *preferred* flag rather than a
label. All other type values are lowercased and joined with ``", "``.
"""
if raw_values is None:
return []
items: list[str | dict] = (
raw_values if isinstance(raw_values, list) else [raw_values]
)
fields: list[ContactField] = []
for item in items:
if isinstance(item, dict):
value = str(item.get("value", ""))
if not value:
continue
raw_types: list[str] = item.get("type") or []
preferred = any(t.upper() == "PREF" for t in raw_types)
labels = [t.lower() for t in raw_types if t.upper() != "PREF"]
fields.append(
ContactField(
type=field_type,
value=value,
label=", ".join(labels) if labels else None,
preferred=preferred,
)
)
elif isinstance(item, str) and item:
fields.append(ContactField(type=field_type, value=item))
return 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`.
"""
contact_info = raw.get("contact", {})
emails = _parse_vcard_fields(contact_info.get("email"), "email")
phones = _parse_vcard_fields(contact_info.get("tel"), "phone")
# Nickname goes into custom_fields (no dedicated model field)
custom_fields: dict[str, Any] = {}
nickname = contact_info.get("nickname")
if nickname:
custom_fields["nickname"] = nickname
return Contact(
uid=raw["vcard_id"],
fn=contact_info.get("fullname", ""),
etag=raw.get("getetag"),
birthday=contact_info.get("birthday"),
emails=emails,
phones=phones,
custom_fields=custom_fields,
)
def configure_contacts_tools(mcp: FastMCP):
# Contacts tools
@mcp.tool(
@@ -18,10 +98,23 @@ def configure_contacts_tools(mcp: FastMCP):
)
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_addressbooks(ctx: Context):
async def nc_contacts_list_addressbooks(ctx: Context) -> ListAddressBooksResponse:
"""List all addressbooks for the user."""
client = await get_client(ctx)
return await client.contacts.list_addressbooks()
addressbooks_data = await client.contacts.list_addressbooks()
addressbooks = [
AddressBook(
# ab["name"] is a short slug like "contacts", not a full CardDAV URI;
# all tools use it as a path segment: f"{carddav_path}/{name}/"
uri=ab["name"],
displayname=ab.get("display_name", ab["name"]),
ctag=ab.get("getctag"),
)
for ab in addressbooks_data
]
return ListAddressBooksResponse(
addressbooks=addressbooks, total_count=len(addressbooks)
)
@mcp.tool(
title="List Contacts",
@@ -29,10 +122,22 @@ def configure_contacts_tools(mcp: FastMCP):
)
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
"""List all contacts in the specified addressbook."""
async def nc_contacts_list_contacts(
ctx: Context, *, addressbook: str
) -> ListContactsResponse:
"""List all contacts in the specified addressbook.
Args:
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
"""
client = await get_client(ctx)
return await client.contacts.list_contacts(addressbook=addressbook)
contacts_data = await client.contacts.list_contacts(addressbook=addressbook)
contacts = [_raw_contact_to_model(c) for c in contacts_data]
return ListContactsResponse(
contacts=contacts, addressbook=addressbook, total_count=len(contacts)
)
@mcp.tool(
title="Create Address Book",
@@ -79,7 +184,9 @@ def configure_contacts_tools(mcp: FastMCP):
"""Create a new contact.
Args:
addressbook: The name of the addressbook to create the contact in.
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
uid: The unique ID for the contact.
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
"""
@@ -97,7 +204,14 @@ def configure_contacts_tools(mcp: FastMCP):
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
"""Delete a contact."""
"""Delete a contact.
Args:
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
uid: The unique ID of the contact to delete.
"""
client = await get_client(ctx)
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
@@ -113,7 +227,9 @@ def configure_contacts_tools(mcp: FastMCP):
"""Update an existing contact while preserving all existing properties.
Args:
addressbook: The name of the addressbook containing the contact.
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
uid: The unique ID of the contact to update.
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
etag: Optional ETag for optimistic concurrency control.
+15 -11
View File
@@ -17,6 +17,10 @@ from nextcloud_mcp_server.models.deck import (
DeckLabel,
DeckStack,
LabelOperationResponse,
ListBoardsResponse,
ListCardsResponse,
ListLabelsResponse,
ListStacksResponse,
StackOperationResponse,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
@@ -103,7 +107,7 @@ def configure_deck_tools(mcp: FastMCP):
)
client = await get_client(ctx)
board = await client.deck.get_board(board_id)
return [label.model_dump() for label in board.labels]
return [label.model_dump() for label in (board.labels or [])]
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
async def deck_label_resource(board_id: int, label_id: int):
@@ -124,11 +128,11 @@ def configure_deck_tools(mcp: FastMCP):
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
"""Get all Nextcloud Deck boards"""
client = await get_client(ctx)
boards = await client.deck.get_boards()
return boards
return ListBoardsResponse(boards=boards, total=len(boards))
@mcp.tool(
title="Get Deck Board",
@@ -148,11 +152,11 @@ def configure_deck_tools(mcp: FastMCP):
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
async def deck_get_stacks(ctx: Context, board_id: int) -> ListStacksResponse:
"""Get all stacks in a Nextcloud Deck board"""
client = await get_client(ctx)
stacks = await client.deck.get_stacks(board_id)
return stacks
return ListStacksResponse(stacks=stacks, total=len(stacks))
@mcp.tool(
title="Get Deck Stack",
@@ -174,13 +178,12 @@ def configure_deck_tools(mcp: FastMCP):
@instrument_tool
async def deck_get_cards(
ctx: Context, board_id: int, stack_id: int
) -> list[DeckCard]:
) -> ListCardsResponse:
"""Get all cards in a Nextcloud Deck stack"""
client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards:
return stack.cards
return []
cards = stack.cards or []
return ListCardsResponse(cards=cards, total=len(cards))
@mcp.tool(
title="Get Deck Card",
@@ -202,11 +205,12 @@ def configure_deck_tools(mcp: FastMCP):
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
async def deck_get_labels(ctx: Context, board_id: int) -> ListLabelsResponse:
"""Get all labels in a Nextcloud Deck board"""
client = await get_client(ctx)
board = await client.deck.get_board(board_id)
return board.labels
labels = board.labels or []
return ListLabelsResponse(labels=labels, total=len(labels))
@mcp.tool(
title="Get Deck Label",
+5 -2
View File
@@ -5,6 +5,7 @@ from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.tables import ListTablesResponse, Table
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
@@ -18,10 +19,12 @@ def configure_tables_tools(mcp: FastMCP):
)
@require_scopes("tables:read")
@instrument_tool
async def nc_tables_list_tables(ctx: Context):
async def nc_tables_list_tables(ctx: Context) -> ListTablesResponse:
"""List all tables available to the user"""
client = await get_client(ctx)
return await client.tables.list_tables()
tables_data = await client.tables.list_tables()
tables = [Table(**t) for t in tables_data]
return ListTablesResponse(tables=tables, total_count=len(tables))
@mcp.tool(
title="Get Table Schema",
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.64.2"
version = "0.64.5"
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"}
@@ -13,7 +13,7 @@ dependencies = [
"mcp[cli] (>=1.26,<1.27)",
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
"icalendar (>=6.0.0,<7.0.0)",
"icalendar (>=7.0.2,<7.1.0)",
"pythonvcard4>=0.2.0",
"pydantic>=2.11.4",
"click>=8.1.8",
@@ -78,11 +78,10 @@ async def test_deck_board_view_permissions(
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
logger.info(f"Bob can see {len(response_data)} boards: {board_ids}")
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Bob can see {len(board_list)} boards: {board_ids}")
# Bob should see the shared board
if board_id in board_ids:
@@ -98,11 +97,10 @@ async def test_deck_board_view_permissions(
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
logger.info(f"Diana can see {len(response_data)} boards")
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Diana can see {len(board_list)} boards")
# Diana should NOT see the board
assert board_id not in board_ids, "Diana should not see board without ACL"
@@ -313,10 +311,9 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Alice can see boards: {board_ids}")
# Alice should NOT see Bob's board
@@ -332,10 +329,9 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Bob can see boards: {board_ids}")
# Bob should NOT see Alice's board
+15 -21
View File
@@ -683,17 +683,15 @@ async def test_mcp_calendar_workflow(
f"MCP list events failed: {list_result.content}"
)
events_data = json.loads(list_result.content[0].text)
events_response = json.loads(list_result.content[0].text)
# Debug output to understand what nc_calendar_list_events returns
logger.info(f"list_events result type: {type(events_data)}")
logger.info(f"list_events result content: {events_data}")
# Handle single event returned as dict instead of list (same fix as calendars)
if isinstance(events_data, dict):
# Single event returned as dict instead of list
events_data = [events_data]
logger.info(f"list_events result type: {type(events_response)}")
logger.info(f"list_events result content: {events_response}")
# Response is now a ListEventsResponse with an "events" field
assert isinstance(events_response, dict), "Expected response dict"
events_data = events_response.get("events", [])
assert isinstance(events_data, list), "Expected events list"
# Our created event should be in the list
@@ -706,7 +704,7 @@ async def test_mcp_calendar_workflow(
assert found_event is not None, (
f"Created event {event_uid} not found in events list"
)
assert found_event["title"] == test_event_title
assert found_event["summary"] == test_event_title
# 6. Test list events across all calendars
logger.info("Testing nc_calendar_list_events across all calendars")
@@ -727,13 +725,11 @@ async def test_mcp_calendar_workflow(
f"MCP list all events failed: {all_list_result.content}"
)
all_events_data = json.loads(all_list_result.content[0].text)
# Handle single event returned as dict instead of list (same fix as calendars)
if isinstance(all_events_data, dict):
# Single event returned as dict instead of list
all_events_data = [all_events_data]
all_events_response = json.loads(all_list_result.content[0].text)
# Response is now a ListEventsResponse with an "events" field
assert isinstance(all_events_response, dict), "Expected response dict"
all_events_data = all_events_response.get("events", [])
assert isinstance(all_events_data, list), "Expected events list"
# Our event should still be found when searching all calendars
@@ -780,13 +776,11 @@ async def test_mcp_calendar_workflow(
f"MCP upcoming events failed: {upcoming_result.content}"
)
upcoming_events = json.loads(upcoming_result.content[0].text)
# Handle single event returned as dict instead of list (same fix as other tools)
if isinstance(upcoming_events, dict):
# Single event returned as dict instead of list
upcoming_events = [upcoming_events]
upcoming_response = json.loads(upcoming_result.content[0].text)
# Response is now an UpcomingEventsResponse with an "events" field
assert isinstance(upcoming_response, dict), "Expected response dict"
upcoming_events = upcoming_response.get("events", [])
assert isinstance(upcoming_events, list), "Expected upcoming events list"
# 10. Delete event via MCP
+368
View File
@@ -2,6 +2,10 @@
import pytest
from nextcloud_mcp_server.models.contacts import (
Contact,
ListContactsResponse,
)
from nextcloud_mcp_server.models.notes import (
CreateNoteResponse,
Note,
@@ -12,6 +16,8 @@ from nextcloud_mcp_server.models.semantic import (
SamplingSearchResponse,
SemanticSearchResult,
)
from nextcloud_mcp_server.server.calendar import _event_dict_to_summary
from nextcloud_mcp_server.server.contacts import _raw_contact_to_model
@pytest.mark.unit
@@ -267,3 +273,365 @@ def test_sampling_search_response_serialization():
assert data["model_used"] == "claude-3-5-sonnet"
assert data["stop_reason"] == "maxTokens"
assert data["success"] is True
def _map_contact(raw: dict) -> Contact:
"""Thin wrapper around the production mapping function for test readability."""
return _raw_contact_to_model(raw)
@pytest.mark.unit
def test_contact_mapping_preserves_email_birthday_nickname():
"""Test that list_contacts mapping preserves email, birthday, and nickname.
Regression test for PR #574: the original mapping only kept uid, fn, etag
and silently dropped email, birthday, and nickname.
"""
raw_contact = {
"vcard_id": "abc-123",
"getetag": '"etag-val"',
"contact": {
"fullname": "Jane Doe",
"email": "jane@example.com",
"birthday": "1990-05-15",
"nickname": "JD",
},
}
contact = _map_contact(raw_contact)
assert contact.uid == "abc-123"
assert contact.fn == "Jane Doe"
assert contact.etag == '"etag-val"'
assert contact.birthday == "1990-05-15"
assert len(contact.emails) == 1
assert contact.emails[0].value == "jane@example.com"
assert contact.emails[0].type == "email"
assert contact.custom_fields["nickname"] == "JD"
@pytest.mark.unit
def test_contact_mapping_multiple_emails():
"""Test that multiple emails are mapped correctly."""
raw_contact = {
"vcard_id": "def-456",
"contact": {
"fullname": "John Smith",
"email": ["john@work.com", "john@home.com"],
},
}
contact = _map_contact(raw_contact)
assert len(contact.emails) == 2
assert contact.emails[0].value == "john@work.com"
assert contact.emails[1].value == "john@home.com"
@pytest.mark.unit
def test_contact_mapping_missing_optional_fields():
"""Test mapping when email, birthday, and nickname are absent."""
raw_contact = {
"vcard_id": "ghi-789",
"contact": {"fullname": "No Details"},
}
contact = _map_contact(raw_contact)
assert contact.uid == "ghi-789"
assert contact.fn == "No Details"
assert contact.birthday is None
assert contact.emails == []
assert contact.custom_fields == {}
@pytest.mark.unit
def test_list_contacts_response_wraps_contacts():
"""Test ListContactsResponse wraps contacts correctly for MCP output."""
contacts = [
_map_contact(
{
"vcard_id": "a",
"getetag": '"e1"',
"contact": {
"fullname": "Alice",
"email": "alice@test.com",
"birthday": "2000-01-01",
"nickname": "Ali",
},
}
),
]
response = ListContactsResponse(
contacts=contacts, addressbook="personal", total_count=1
)
data = response.model_dump()
assert data["total_count"] == 1
assert len(data["contacts"]) == 1
c = data["contacts"][0]
assert c["birthday"] == "2000-01-01"
assert c["emails"][0]["value"] == "alice@test.com"
assert c["custom_fields"]["nickname"] == "Ali"
@pytest.mark.unit
def test_contact_mapping_dict_format_emails():
"""Regression for #601: pythonvCard4 returns dicts, not plain strings."""
raw_contact = {
"vcard_id": "dict-email-1",
"contact": {
"fullname": "Evrim Yilmaz",
"email": [
{"value": "evrim@example.com", "type": ["HOME"]},
{"value": "evrim@work.com", "type": ["WORK"]},
],
},
}
contact = _map_contact(raw_contact)
assert len(contact.emails) == 2
assert contact.emails[0].value == "evrim@example.com"
assert contact.emails[0].label == "home"
assert contact.emails[1].value == "evrim@work.com"
assert contact.emails[1].label == "work"
@pytest.mark.unit
def test_contact_mapping_dict_format_phones():
"""Phones from dict-format tel field are parsed into Contact.phones."""
raw_contact = {
"vcard_id": "dict-tel-1",
"contact": {
"fullname": "Phone User",
"tel": [
{"value": "+1-555-0100", "type": ["CELL"]},
{"value": "+1-555-0200", "type": ["WORK", "VOICE"]},
],
},
}
contact = _map_contact(raw_contact)
assert len(contact.phones) == 2
assert contact.phones[0].value == "+1-555-0100"
assert contact.phones[0].type == "phone"
assert contact.phones[0].label == "cell"
assert contact.phones[1].value == "+1-555-0200"
assert contact.phones[1].label == "work, voice"
@pytest.mark.unit
def test_contact_mapping_pref_flag_extraction():
"""PREF type is extracted as preferred=True, not included in labels."""
raw_contact = {
"vcard_id": "pref-1",
"contact": {
"fullname": "Pref User",
"email": [
{"value": "pref@example.com", "type": ["HOME", "PREF"]},
{"value": "other@example.com", "type": ["WORK"]},
],
"tel": [
{"value": "+1-555-0001", "type": ["pref", "CELL"]},
],
},
}
contact = _map_contact(raw_contact)
assert contact.emails[0].preferred is True
assert contact.emails[0].label == "home" # PREF stripped from label
assert contact.emails[1].preferred is False
assert contact.primary_email == "pref@example.com"
assert contact.phones[0].preferred is True
assert contact.phones[0].label == "cell"
assert contact.primary_phone == "+1-555-0001"
@pytest.mark.unit
def test_contact_mapping_backward_compat_plain_strings():
"""Plain string emails/phones still work (backward compatibility)."""
raw_contact = {
"vcard_id": "compat-1",
"contact": {
"fullname": "Plain String",
"email": "plain@example.com",
"tel": "+1-555-9999",
},
}
contact = _map_contact(raw_contact)
assert len(contact.emails) == 1
assert contact.emails[0].value == "plain@example.com"
assert contact.emails[0].label is None
assert contact.emails[0].preferred is False
assert len(contact.phones) == 1
assert contact.phones[0].value == "+1-555-9999"
@pytest.mark.unit
def test_contact_mapping_empty_type_list():
"""Dict with empty or missing type list produces no label."""
raw_contact = {
"vcard_id": "empty-type-1",
"contact": {
"fullname": "No Type",
"email": {"value": "notype@example.com", "type": []},
},
}
contact = _map_contact(raw_contact)
assert len(contact.emails) == 1
assert contact.emails[0].value == "notype@example.com"
assert contact.emails[0].label is None
assert contact.emails[0].preferred is False
@pytest.mark.unit
def test_contact_mapping_multiple_dict_emails_with_labels():
"""Multiple dict-format emails preserve individual labels."""
raw_contact = {
"vcard_id": "multi-label-1",
"contact": {
"fullname": "Multi Label",
"email": [
{"value": "home@example.com", "type": ["HOME", "PREF"]},
{"value": "work@example.com", "type": ["WORK"]},
{"value": "other@example.com"},
],
},
}
contact = _map_contact(raw_contact)
assert len(contact.emails) == 3
assert contact.emails[0].value == "home@example.com"
assert contact.emails[0].label == "home"
assert contact.emails[0].preferred is True
assert contact.emails[1].value == "work@example.com"
assert contact.emails[1].label == "work"
assert contact.emails[1].preferred is False
assert contact.emails[2].value == "other@example.com"
assert contact.emails[2].label is None
assert contact.primary_email == "home@example.com"
# ============= _event_dict_to_summary tests =============
@pytest.mark.unit
def test_event_dict_to_summary_basic():
"""Test basic mapping with all fields populated."""
event = {
"uid": "evt-001",
"title": "Team Standup",
"start_datetime": "2025-07-28T09:00:00",
"end_datetime": "2025-07-28T09:30:00",
"all_day": False,
"location": "Room 42",
"description": "Daily sync",
"categories": ["work", "meeting"],
"status": "CONFIRMED",
"calendar_name": "office",
"calendar_display_name": "Office Calendar",
}
summary = _event_dict_to_summary(event)
assert summary.uid == "evt-001"
assert summary.summary == "Team Standup"
assert summary.start == "2025-07-28T09:00:00"
assert summary.end == "2025-07-28T09:30:00"
assert summary.all_day is False
assert summary.location == "Room 42"
assert summary.description == "Daily sync"
assert summary.categories == ["work", "meeting"]
assert summary.status == "CONFIRMED"
assert summary.calendar_name == "office"
assert summary.calendar_display_name == "Office Calendar"
@pytest.mark.unit
def test_event_dict_to_summary_categories_string():
"""Test that comma-separated category string is split into a list."""
event = {
"uid": "evt-002",
"title": "Review",
"categories": "work, meeting, important",
}
summary = _event_dict_to_summary(event)
assert summary.categories == ["work", "meeting", "important"]
@pytest.mark.unit
def test_event_dict_to_summary_categories_list_passthrough():
"""Test that a list of categories passes through unchanged."""
event = {
"uid": "evt-003",
"title": "Review",
"categories": ["personal", "health"],
}
summary = _event_dict_to_summary(event)
assert summary.categories == ["personal", "health"]
@pytest.mark.unit
def test_event_dict_to_summary_falsy_location_description():
"""Test that empty/falsy location and description are coerced to None."""
event = {
"uid": "evt-004",
"title": "Quick Chat",
"location": "",
"description": "",
}
summary = _event_dict_to_summary(event)
assert summary.location is None
assert summary.description is None
@pytest.mark.unit
def test_event_dict_to_summary_missing_optional_fields():
"""Test mapping with only required fields present."""
event = {"uid": "evt-005", "title": "Minimal Event"}
summary = _event_dict_to_summary(event)
assert summary.uid == "evt-005"
assert summary.summary == "Minimal Event"
assert summary.start == ""
assert summary.end is None
assert summary.all_day is False
assert summary.location is None
assert summary.description is None
assert summary.categories == []
assert summary.status is None
assert summary.calendar_name is None
assert summary.calendar_display_name is None
@pytest.mark.unit
def test_event_dict_to_summary_calendar_name_without_display_name():
"""Test single-calendar path: calendar_name set, display_name absent falls back."""
event = {
"uid": "evt-006",
"title": "Personal Errand",
"calendar_name": "personal",
}
summary = _event_dict_to_summary(event)
assert summary.calendar_name == "personal"
assert summary.calendar_display_name == "personal"
Generated
+12 -11
View File
@@ -1166,15 +1166,16 @@ wheels = [
[[package]]
name = "icalendar"
version = "6.3.2"
version = "7.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422, upload-time = "2025-11-05T12:49:32.286Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/51/43458f01e229763b05dd937b5e9d41ef506b6eb8b4bf939f8ea34350b853/icalendar-7.0.2.tar.gz", hash = "sha256:de844ff5cde32f539bea7644e36d8494032a926b933bedb92621f2f239760806", size = 440039, upload-time = "2026-02-24T16:13:42.887Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403, upload-time = "2025-11-05T12:49:30.691Z" },
{ url = "https://files.pythonhosted.org/packages/62/ab/e0d44b1de0beb703bbc507ca064300b34046f9f9628f052d1a97ffa61b95/icalendar-7.0.2-py3-none-any.whl", hash = "sha256:ad31a5825b39522a30b073c6ced3ffcdf6c02cbb7dab69ba2e4de32ddbf77cc9", size = 437913, upload-time = "2026-02-24T16:13:40.631Z" },
]
[[package]]
@@ -1988,7 +1989,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.64.2"
version = "0.64.5"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -2051,7 +2052,7 @@ requires-dist = [
{ name = "click", specifier = ">=8.1.8" },
{ name = "fastembed", specifier = ">=0.7.3" },
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
{ name = "icalendar", specifier = ">=6.0.0,<7.0.0" },
{ name = "icalendar", specifier = ">=7.0.2,<7.1.0" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
{ name = "markdownify", specifier = ">=0.14.1" },
@@ -3383,7 +3384,7 @@ wheels = [
[[package]]
name = "recurring-ical-events"
version = "3.8.0"
version = "3.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "icalendar" },
@@ -3391,9 +3392,9 @@ dependencies = [
{ name = "tzdata" },
{ name = "x-wr-timezone" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/90/05dfcc02ecf58bd170305c88db9e3e3933aa73a1f8abac2b326c7fdc1a98/recurring_ical_events-3.8.0.tar.gz", hash = "sha256:3e8c7c35d9bd8956a7ab91afad51477c60d972e1236d3fd1b55087a66bce7d04", size = 602665, upload-time = "2025-06-10T13:23:50.662Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/d4/51c9361bb0efb2290dfd850c036b49acb502794e0fe9cc3520dbf60fd7db/recurring_ical_events-3.8.1.tar.gz", hash = "sha256:c3eb2490a00559fb963d2bdee39acf2f287c91c07dcea4ce80ade1c60a8c3acf", size = 603730, upload-time = "2026-02-18T11:45:53.272Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/25/88a4218cccae06ce6b15e41d2f263dd4a73e8e8cbe41537cd7784a17479b/recurring_ical_events-3.8.0-py3-none-any.whl", hash = "sha256:cf958eb17c92d4dca5c621e44c2b3fffd4ba700dca0db66287c5dc11438f63ba", size = 238228, upload-time = "2025-06-10T13:23:49.048Z" },
{ url = "https://files.pythonhosted.org/packages/14/67/4d4aead359164de68d30ee67efcdbe3784063cb21535c85b9a9a03dd2ebb/recurring_ical_events-3.8.1-py3-none-any.whl", hash = "sha256:3bb3aaa0c87a4d3ab5951360480686bd69f1512945f478be6a2c0f141da0bf78", size = 238286, upload-time = "2026-02-18T11:45:51.631Z" },
]
[[package]]
@@ -3954,11 +3955,11 @@ wheels = [
[[package]]
name = "tzdata"
version = "2025.2"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]