Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80366a4e1e | |||
| 91941a9ece | |||
| 8fd6f4158f | |||
| b8e6539b6f | |||
| fe53e93fe9 | |||
| 71d4c44b05 | |||
| 6443aca743 | |||
| a1b5e676e9 | |||
| 1d9168f614 | |||
| 9229440a58 | |||
| e507f29e83 | |||
| 5ac6d8d396 | |||
| ab71003c5d | |||
| 726b71eea1 | |||
| 3e50924169 | |||
| b2773317ef | |||
| dce3ca9a70 | |||
| 18e5baf2a5 | |||
| 24bc29ea64 | |||
| 44e7e2e09b | |||
| bcc0bfee8d | |||
| 0f31d16158 | |||
| 7c0b84d398 | |||
| f51b27ba19 | |||
| 010eb40d5c | |||
| 960d060d27 | |||
| 76e6c12b56 | |||
| 76e305006c | |||
| 8887aa241a | |||
| 10d44edf4c |
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code Review
|
- name: Run Claude Code Review
|
||||||
id: claude-review
|
id: claude-review
|
||||||
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
|
uses: anthropics/claude-code-action@fa3312a107acfa81df144e00a50bb0ef147f0eef # v1.0.56
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
allowed_bots: "renovate-bot-cbcoutinho"
|
allowed_bots: "renovate-bot-cbcoutinho"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
|
uses: anthropics/claude-code-action@fa3312a107acfa81df144e00a50bb0ef147f0eef # v1.0.56
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ 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/),
|
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/).
|
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||||
|
|
||||||
|
## 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)
|
## v0.64.2 (2026-02-20)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.57.69"
|
version = "0.57.78"
|
||||||
tag_format = "nextcloud-mcp-server-$version"
|
tag_format = "nextcloud-mcp-server-$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
|
|||||||
@@ -14,6 +14,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Configurable resource limits
|
- Configurable resource limits
|
||||||
- Grafana dashboard annotations
|
- Grafana dashboard annotations
|
||||||
|
|
||||||
|
## 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.69 (2026-02-20)
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.68 (2026-02-19)
|
## nextcloud-mcp-server-0.57.68 (2026-02-19)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
version: 1.16.3
|
version: 1.17.0
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.43.0
|
version: 1.45.0
|
||||||
digest: sha256:533eda3fdb4bd92cdafee49bd7b0428fc87d21b509032442c04ed645900e464a
|
digest: sha256:a325b7093a64921fb5c6648c19c31a61799c8b279da21f08b9e892a9e5a37227
|
||||||
generated: "2026-02-16T11:16:41.257136832Z"
|
generated: "2026-02-23T05:14:08.147145912Z"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.57.69
|
version: 0.57.78
|
||||||
appVersion: "0.64.2"
|
appVersion: "0.64.3"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -27,10 +27,10 @@ annotations:
|
|||||||
grafana_dashboard_folder: "Nextcloud MCP"
|
grafana_dashboard_folder: "Nextcloud MCP"
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
version: "1.16.3"
|
version: "1.17.0"
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
version: "1.43.0"
|
version: "1.45.0"
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
condition: ollama.enabled
|
condition: ollama.enabled
|
||||||
|
|||||||
+3
-3
@@ -19,7 +19,7 @@ services:
|
|||||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||||
# https://hub.docker.com/_/redis
|
# https://hub.docker.com/_/redis
|
||||||
redis:
|
redis:
|
||||||
image: docker.io/library/redis:alpine@sha256:fd83658b0e40e2164617d262f13c02ca9ee9e1e6b276fd2fa06617e09bd5c780
|
image: docker.io/library/redis:alpine@sha256:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
@@ -60,7 +60,7 @@ services:
|
|||||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
|
||||||
unstructured:
|
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
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8002:8000
|
- 127.0.0.1:8002:8000
|
||||||
@@ -207,7 +207,7 @@ services:
|
|||||||
- oauth-tokens:/app/data
|
- oauth-tokens:/app/data
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.5.3@sha256:5a236ae4dd8ece77490115bace15a11a4d15e9cbcf58a490b95a7da2cd71d32a
|
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
|
||||||
command:
|
command:
|
||||||
- "start-dev"
|
- "start-dev"
|
||||||
- "--import-realm"
|
- "--import-realm"
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ class CalendarEventSummary(BaseModel):
|
|||||||
status: Optional[str] = Field(
|
status: Optional[str] = Field(
|
||||||
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
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):
|
class CalendarEvent(CalendarEventSummary):
|
||||||
|
|||||||
@@ -261,6 +261,20 @@ class CreateLabelResponse(BaseResponse):
|
|||||||
color: str = Field(description="The created label color")
|
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):
|
class LabelOperationResponse(StatusResponse):
|
||||||
"""Response model for label operations like update/delete."""
|
"""Response model for label operations like update/delete."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,46 @@ from nextcloud_mcp_server.auth import require_scopes
|
|||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.calendar import (
|
from nextcloud_mcp_server.models.calendar import (
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CalendarEventSummary,
|
||||||
ListCalendarsResponse,
|
ListCalendarsResponse,
|
||||||
|
ListEventsResponse,
|
||||||
ListTodosResponse,
|
ListTodosResponse,
|
||||||
Todo,
|
Todo,
|
||||||
|
UpcomingEventsResponse,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
def configure_calendar_tools(mcp: FastMCP):
|
||||||
# Calendar tools
|
# Calendar tools
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -204,7 +235,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
end_datetime=end_datetime,
|
end_datetime=end_datetime,
|
||||||
filters=filters if filters else None,
|
filters=filters if filters else None,
|
||||||
)
|
)
|
||||||
return events[:limit]
|
events = events[:limit]
|
||||||
else:
|
else:
|
||||||
# Search in specific calendar
|
# Search in specific calendar
|
||||||
events = await client.calendar.get_calendar_events(
|
events = await client.calendar.get_calendar_events(
|
||||||
@@ -214,11 +245,25 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
limit=limit,
|
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
|
# Apply filters if provided
|
||||||
if filters:
|
if filters:
|
||||||
events = client.calendar._apply_event_filters(events, 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(
|
@mcp.tool(
|
||||||
title="Get Calendar Event",
|
title="Get Calendar Event",
|
||||||
@@ -420,12 +465,15 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
if calendar_name:
|
if calendar_name:
|
||||||
# Get events from specific calendar
|
# Get events from specific calendar
|
||||||
return await client.calendar.get_calendar_events(
|
events = await client.calendar.get_calendar_events(
|
||||||
calendar_name=calendar_name,
|
calendar_name=calendar_name,
|
||||||
start_datetime=now,
|
start_datetime=now,
|
||||||
end_datetime=end_datetime,
|
end_datetime=end_datetime,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
# calendar_display_name not available without extra API call
|
||||||
|
for event in events:
|
||||||
|
event["calendar_name"] = calendar_name
|
||||||
else:
|
else:
|
||||||
# Get events from all calendars
|
# Get events from all calendars
|
||||||
all_calendars = await client.calendar.list_calendars()
|
all_calendars = await client.calendar.list_calendars()
|
||||||
@@ -433,17 +481,16 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
for calendar in all_calendars:
|
for calendar in all_calendars:
|
||||||
try:
|
try:
|
||||||
events = await client.calendar.get_calendar_events(
|
cal_events = await client.calendar.get_calendar_events(
|
||||||
calendar_name=calendar["name"],
|
calendar_name=calendar["name"],
|
||||||
start_datetime=now,
|
start_datetime=now,
|
||||||
end_datetime=end_datetime,
|
end_datetime=end_datetime,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
# Add calendar info to each event
|
for event in cal_events:
|
||||||
for event in events:
|
|
||||||
event["calendar_name"] = calendar["name"]
|
event["calendar_name"] = calendar["name"]
|
||||||
event["calendar_display_name"] = calendar["display_name"]
|
event["calendar_display_name"] = calendar["display_name"]
|
||||||
all_events.extend(events)
|
all_events.extend(cal_events)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Error getting events from calendar {calendar['name']}: {e}"
|
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
|
# Sort by start time and limit
|
||||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
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(
|
@mcp.tool(
|
||||||
title="Find Availability",
|
title="Find Availability",
|
||||||
|
|||||||
@@ -1,15 +1,57 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
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
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_contact_to_model(raw: dict) -> Contact:
|
||||||
|
"""Convert a raw contact dict from the contacts client to a Contact model.
|
||||||
|
|
||||||
|
Only maps fields the client's list_contacts() currently returns:
|
||||||
|
fullname, nickname, birthday, and email. Additional Contact model fields
|
||||||
|
(phones, addresses, organization, etc.) require expanding the client's
|
||||||
|
vCard parsing in ContactsClient.list_contacts().
|
||||||
|
"""
|
||||||
|
contact_info = raw.get("contact", {})
|
||||||
|
|
||||||
|
# Convert email field (str, list, or None) to list[ContactField]
|
||||||
|
raw_email = contact_info.get("email")
|
||||||
|
emails: list[ContactField] = []
|
||||||
|
if isinstance(raw_email, list):
|
||||||
|
emails = [ContactField(type="email", value=e) for e in raw_email if e]
|
||||||
|
elif isinstance(raw_email, str) and raw_email:
|
||||||
|
emails = [ContactField(type="email", value=raw_email)]
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
custom_fields=custom_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def configure_contacts_tools(mcp: FastMCP):
|
def configure_contacts_tools(mcp: FastMCP):
|
||||||
# Contacts tools
|
# Contacts tools
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -18,10 +60,23 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("contacts:read")
|
@require_scopes("contacts:read")
|
||||||
@instrument_tool
|
@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."""
|
"""List all addressbooks for the user."""
|
||||||
client = await get_client(ctx)
|
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(
|
@mcp.tool(
|
||||||
title="List Contacts",
|
title="List Contacts",
|
||||||
@@ -29,10 +84,16 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("contacts:read")
|
@require_scopes("contacts:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
async def nc_contacts_list_contacts(
|
||||||
|
ctx: Context, *, addressbook: str
|
||||||
|
) -> ListContactsResponse:
|
||||||
"""List all contacts in the specified addressbook."""
|
"""List all contacts in the specified addressbook."""
|
||||||
client = await get_client(ctx)
|
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(
|
@mcp.tool(
|
||||||
title="Create Address Book",
|
title="Create Address Book",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ from nextcloud_mcp_server.models.deck import (
|
|||||||
DeckLabel,
|
DeckLabel,
|
||||||
DeckStack,
|
DeckStack,
|
||||||
LabelOperationResponse,
|
LabelOperationResponse,
|
||||||
|
ListBoardsResponse,
|
||||||
|
ListCardsResponse,
|
||||||
|
ListLabelsResponse,
|
||||||
|
ListStacksResponse,
|
||||||
StackOperationResponse,
|
StackOperationResponse,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
@@ -103,7 +107,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
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}")
|
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
|
||||||
async def deck_label_resource(board_id: int, label_id: int):
|
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")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
|
||||||
"""Get all Nextcloud Deck boards"""
|
"""Get all Nextcloud Deck boards"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
boards = await client.deck.get_boards()
|
boards = await client.deck.get_boards()
|
||||||
return boards
|
return ListBoardsResponse(boards=boards, total=len(boards))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Board",
|
title="Get Deck Board",
|
||||||
@@ -148,11 +152,11 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@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"""
|
"""Get all stacks in a Nextcloud Deck board"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
stacks = await client.deck.get_stacks(board_id)
|
stacks = await client.deck.get_stacks(board_id)
|
||||||
return stacks
|
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Stack",
|
title="Get Deck Stack",
|
||||||
@@ -174,13 +178,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_cards(
|
async def deck_get_cards(
|
||||||
ctx: Context, board_id: int, stack_id: int
|
ctx: Context, board_id: int, stack_id: int
|
||||||
) -> list[DeckCard]:
|
) -> ListCardsResponse:
|
||||||
"""Get all cards in a Nextcloud Deck stack"""
|
"""Get all cards in a Nextcloud Deck stack"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
if stack.cards:
|
cards = stack.cards or []
|
||||||
return stack.cards
|
return ListCardsResponse(cards=cards, total=len(cards))
|
||||||
return []
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Card",
|
title="Get Deck Card",
|
||||||
@@ -202,11 +205,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@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"""
|
"""Get all labels in a Nextcloud Deck board"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return board.labels
|
labels = board.labels or []
|
||||||
|
return ListLabelsResponse(labels=labels, total=len(labels))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Label",
|
title="Get Deck Label",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from mcp.types import ToolAnnotations
|
|||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
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
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -18,10 +19,12 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("tables:read")
|
@require_scopes("tables:read")
|
||||||
@instrument_tool
|
@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"""
|
"""List all tables available to the user"""
|
||||||
client = await get_client(ctx)
|
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(
|
@mcp.tool(
|
||||||
title="Get Table Schema",
|
title="Get Table Schema",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.64.2"
|
version = "0.64.3"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
|
|||||||
@@ -78,11 +78,10 @@ async def test_deck_board_view_permissions(
|
|||||||
|
|
||||||
if not result.isError:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# The response is directly a list of boards
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if not isinstance(response_data, list):
|
board_list = response_data.get("boards", [])
|
||||||
response_data = [response_data] if response_data else []
|
board_ids = [b["id"] for b in board_list]
|
||||||
board_ids = [b["id"] for b in response_data]
|
logger.info(f"Bob can see {len(board_list)} boards: {board_ids}")
|
||||||
logger.info(f"Bob can see {len(response_data)} boards: {board_ids}")
|
|
||||||
|
|
||||||
# Bob should see the shared board
|
# Bob should see the shared board
|
||||||
if board_id in board_ids:
|
if board_id in board_ids:
|
||||||
@@ -98,11 +97,10 @@ async def test_deck_board_view_permissions(
|
|||||||
|
|
||||||
if not result.isError:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# The response is directly a list of boards
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if not isinstance(response_data, list):
|
board_list = response_data.get("boards", [])
|
||||||
response_data = [response_data] if response_data else []
|
board_ids = [b["id"] for b in board_list]
|
||||||
board_ids = [b["id"] for b in response_data]
|
logger.info(f"Diana can see {len(board_list)} boards")
|
||||||
logger.info(f"Diana can see {len(response_data)} boards")
|
|
||||||
|
|
||||||
# Diana should NOT see the board
|
# Diana should NOT see the board
|
||||||
assert board_id not in board_ids, "Diana should not see board without ACL"
|
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:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# The response is directly a list of boards
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if not isinstance(response_data, list):
|
board_list = response_data.get("boards", [])
|
||||||
response_data = [response_data] if response_data else []
|
board_ids = [b["id"] for b in board_list]
|
||||||
board_ids = [b["id"] for b in response_data]
|
|
||||||
logger.info(f"Alice can see boards: {board_ids}")
|
logger.info(f"Alice can see boards: {board_ids}")
|
||||||
|
|
||||||
# Alice should NOT see Bob's board
|
# 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:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# The response is directly a list of boards
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if not isinstance(response_data, list):
|
board_list = response_data.get("boards", [])
|
||||||
response_data = [response_data] if response_data else []
|
board_ids = [b["id"] for b in board_list]
|
||||||
board_ids = [b["id"] for b in response_data]
|
|
||||||
logger.info(f"Bob can see boards: {board_ids}")
|
logger.info(f"Bob can see boards: {board_ids}")
|
||||||
|
|
||||||
# Bob should NOT see Alice's board
|
# Bob should NOT see Alice's board
|
||||||
|
|||||||
+15
-21
@@ -683,17 +683,15 @@ async def test_mcp_calendar_workflow(
|
|||||||
f"MCP list events failed: {list_result.content}"
|
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
|
# 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 type: {type(events_response)}")
|
||||||
logger.info(f"list_events result content: {events_data}")
|
logger.info(f"list_events result content: {events_response}")
|
||||||
|
|
||||||
# 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]
|
|
||||||
|
|
||||||
|
# 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"
|
assert isinstance(events_data, list), "Expected events list"
|
||||||
|
|
||||||
# Our created event should be in the 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, (
|
assert found_event is not None, (
|
||||||
f"Created event {event_uid} not found in events list"
|
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
|
# 6. Test list events across all calendars
|
||||||
logger.info("Testing nc_calendar_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}"
|
f"MCP list all events failed: {all_list_result.content}"
|
||||||
)
|
)
|
||||||
|
|
||||||
all_events_data = json.loads(all_list_result.content[0].text)
|
all_events_response = 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]
|
|
||||||
|
|
||||||
|
# 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"
|
assert isinstance(all_events_data, list), "Expected events list"
|
||||||
|
|
||||||
# Our event should still be found when searching all calendars
|
# 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}"
|
f"MCP upcoming events failed: {upcoming_result.content}"
|
||||||
)
|
)
|
||||||
|
|
||||||
upcoming_events = json.loads(upcoming_result.content[0].text)
|
upcoming_response = 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]
|
|
||||||
|
|
||||||
|
# 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"
|
assert isinstance(upcoming_events, list), "Expected upcoming events list"
|
||||||
|
|
||||||
# 10. Delete event via MCP
|
# 10. Delete event via MCP
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.models.contacts import (
|
||||||
|
Contact,
|
||||||
|
ListContactsResponse,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.models.notes import (
|
from nextcloud_mcp_server.models.notes import (
|
||||||
CreateNoteResponse,
|
CreateNoteResponse,
|
||||||
Note,
|
Note,
|
||||||
@@ -12,6 +16,8 @@ from nextcloud_mcp_server.models.semantic import (
|
|||||||
SamplingSearchResponse,
|
SamplingSearchResponse,
|
||||||
SemanticSearchResult,
|
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
|
@pytest.mark.unit
|
||||||
@@ -267,3 +273,218 @@ def test_sampling_search_response_serialization():
|
|||||||
assert data["model_used"] == "claude-3-5-sonnet"
|
assert data["model_used"] == "claude-3-5-sonnet"
|
||||||
assert data["stop_reason"] == "maxTokens"
|
assert data["stop_reason"] == "maxTokens"
|
||||||
assert data["success"] is True
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
# ============= _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"
|
||||||
|
|||||||
@@ -1988,7 +1988,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.64.2"
|
version = "0.64.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user