Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92f2d74637 | |||
| 656acc2c1f | |||
| c726e25e8b | |||
| 355bd1bad3 | |||
| 989d3f2857 | |||
| 92d5cd4e26 | |||
| 5823286907 | |||
| 7fb6613bc2 | |||
| cd6f0ffa63 | |||
| 5d98858bb6 | |||
| af7c752cc1 | |||
| 2526390ce8 | |||
| 0b5571f3d7 | |||
| 059f37d093 | |||
| 28ad0aefbf | |||
| 6ce9599757 | |||
| 1cdf148899 | |||
| 8b16d79d6c | |||
| 45cc4c68fc | |||
| b4c98b25ee | |||
| 1176479ec1 | |||
| 0f8b1c6325 | |||
| fdb7b87baf | |||
| 184415eca1 | |||
| 658fd7e138 | |||
| a5d2025797 | |||
| 08aaa85ab3 | |||
| ecab777efa | |||
| c960560716 | |||
| 023927afff | |||
| 3a87b33288 | |||
| c8ebd9c089 |
@@ -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@ba7fa4bcf054319261202aef93d71a89112a8d00 # v1.0.64
|
uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
|
||||||
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@ba7fa4bcf054319261202aef93d71a89112a8d00 # v1.0.64
|
uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud to be ready
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||||
- name: Install Python 3.11
|
- name: Install Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: uv run --frozen ruff format --diff
|
run: uv run --frozen ruff format --diff
|
||||||
- name: Linting
|
- name: Linting
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: uv run pytest -v -m unit -o "addopts=-p no:asyncio"
|
run: uv run pytest -v -m unit -o "addopts=-p no:asyncio"
|
||||||
|
|
||||||
@@ -48,14 +48,14 @@ jobs:
|
|||||||
# Version-specific image pins — Renovate updates these via customManagers
|
# Version-specific image pins — Renovate updates these via customManagers
|
||||||
# renovate: datasource=docker depName=docker.io/library/nextcloud
|
# renovate: datasource=docker depName=docker.io/library/nextcloud
|
||||||
- nextcloud_version: "31"
|
- nextcloud_version: "31"
|
||||||
nextcloud_image: "docker.io/library/nextcloud:31.0.8@sha256:92bc503ea0c19789f402b0469ecfb8df1ffea81e2bf90a45bba39063a626aa00"
|
nextcloud_image: "docker.io/library/nextcloud:31.0.14@sha256:9bf3fae91aad4dca3eff02c1f71df8d5c6705a349065fb537aa5c5ef578f1013"
|
||||||
# renovate: datasource=docker depName=docker.io/library/nextcloud
|
# renovate: datasource=docker depName=docker.io/library/nextcloud
|
||||||
- nextcloud_version: "32"
|
- nextcloud_version: "32"
|
||||||
nextcloud_image: "docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267"
|
nextcloud_image: "docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267"
|
||||||
# renovate: datasource=docker depName=docker.io/library/nextcloud
|
# renovate: datasource=docker depName=docker.io/library/nextcloud
|
||||||
# Disabled until all upstream apps support NC 33
|
# Disabled until all upstream apps support NC 33
|
||||||
# - nextcloud_version: "33"
|
# - nextcloud_version: "33"
|
||||||
# nextcloud_image: "docker.io/library/nextcloud:33.0.0@sha256:ff2cbaab14c85e587b5541e3aff4216a8a484e06424ebae661581937c0c8da0c"
|
# nextcloud_image: "docker.io/library/nextcloud:33.0.0@sha256:d53f6cb35b0712aa890a5e4a8ca21043d6fcd390f38c55b710816dd7cbc2edc0"
|
||||||
|
|
||||||
# Mode-specific properties
|
# Mode-specific properties
|
||||||
- mode: single-user
|
- mode: single-user
|
||||||
@@ -112,7 +112,7 @@ jobs:
|
|||||||
if: matrix.mode != 'single-user'
|
if: matrix.mode != 'single-user'
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Build Astrolabe app
|
- name: Build Astrolabe app
|
||||||
if: matrix.mode != 'single-user'
|
if: matrix.mode != 'single-user'
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
NEXTCLOUD_IMAGE: ${{ matrix.nextcloud_image }}
|
NEXTCLOUD_IMAGE: ${{ matrix.nextcloud_image }}
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||||
|
|
||||||
- name: Install Playwright
|
- name: Install Playwright
|
||||||
if: matrix.needs-playwright
|
if: matrix.needs-playwright
|
||||||
|
|||||||
@@ -5,6 +5,41 @@ 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.65.0 (2026-03-03)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **auth**: implement OAuth AS proxy to fix audience mismatch (ADR-023)
|
||||||
|
- **ci**: add Nextcloud version matrix (NC 31, 32, 33)
|
||||||
|
- **helm**: add login-flow auth mode to Helm chart (ADR-022)
|
||||||
|
- add Docker Compose profiles and Login Flow v2 service
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- replace assert with proper guard and invalidate scope cache after provisioning
|
||||||
|
- disable NC rate limiting in dev/CI and add token endpoint diagnostics
|
||||||
|
- address review feedback — security, caching, CI 429 retry
|
||||||
|
- skip keycloak hook when profile inactive and update stale PRM test
|
||||||
|
- address remaining PR #589 review findings
|
||||||
|
- address PR #589 review findings
|
||||||
|
- address PR review issues for Login Flow v2
|
||||||
|
- address PR #589 review feedback (round 2)
|
||||||
|
- **ci**: remove dev OIDC mount to fix HTTP 500 in single-user/multi-user-basic
|
||||||
|
- **ci**: fix health check timeout and per-profile MCP server URL routing
|
||||||
|
- **ci**: fix PHP gating, add multi-user-basic matrix entry, upload debug artifacts
|
||||||
|
- address PR #589 review feedback for Login Flow v2
|
||||||
|
- **ci**: fix integration test collection and skip Playwright in CI
|
||||||
|
- **test**: fix 17 pre-existing unit test failures and add astrolabe CI build
|
||||||
|
- **ci**: keep third_party mount, always build submodules in CI
|
||||||
|
- **ci**: revert accidental third_party mount, use compose override for OIDC
|
||||||
|
- **ci**: don't block integration matrix on unit-test failures
|
||||||
|
|
||||||
|
## v0.64.5 (2026-03-03)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- handle pythonvCard4 dict-format fields and missing phone numbers (#601)
|
||||||
|
|
||||||
## v0.64.4 (2026-02-26)
|
## v0.64.4 (2026-02-26)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.57.90"
|
version = "0.58.3"
|
||||||
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,53 @@ 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.58.3 (2026-03-16)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.58.2 (2026-03-14)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.58.1 (2026-03-03)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.58.0 (2026-03-03)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **auth**: implement OAuth AS proxy to fix audience mismatch (ADR-023)
|
||||||
|
- **ci**: add Nextcloud version matrix (NC 31, 32, 33)
|
||||||
|
- **helm**: add login-flow auth mode to Helm chart (ADR-022)
|
||||||
|
- add Docker Compose profiles and Login Flow v2 service
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- replace assert with proper guard and invalidate scope cache after provisioning
|
||||||
|
- disable NC rate limiting in dev/CI and add token endpoint diagnostics
|
||||||
|
- address review feedback — security, caching, CI 429 retry
|
||||||
|
- skip keycloak hook when profile inactive and update stale PRM test
|
||||||
|
- address remaining PR #589 review findings
|
||||||
|
- address PR #589 review findings
|
||||||
|
- address PR review issues for Login Flow v2
|
||||||
|
- address PR #589 review feedback (round 2)
|
||||||
|
- **ci**: remove dev OIDC mount to fix HTTP 500 in single-user/multi-user-basic
|
||||||
|
- **ci**: fix health check timeout and per-profile MCP server URL routing
|
||||||
|
- **ci**: fix PHP gating, add multi-user-basic matrix entry, upload debug artifacts
|
||||||
|
- address PR #589 review feedback for Login Flow v2
|
||||||
|
- **ci**: fix integration test collection and skip Playwright in CI
|
||||||
|
- **test**: fix 17 pre-existing unit test failures and add astrolabe CI build
|
||||||
|
- **ci**: keep third_party mount, always build submodules in CI
|
||||||
|
- **ci**: revert accidental third_party mount, use compose override for OIDC
|
||||||
|
- **ci**: don't block integration matrix on unit-test failures
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.94 (2026-03-03)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- handle pythonvCard4 dict-format fields and missing phone numbers (#601)
|
||||||
|
|
||||||
|
## 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.90 (2026-03-01)
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.89 (2026-03-01)
|
## nextcloud-mcp-server-0.57.89 (2026-03-01)
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ dependencies:
|
|||||||
version: 1.17.0
|
version: 1.17.0
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.46.0
|
version: 1.47.0
|
||||||
digest: sha256:89505cf985f3b838f68b55484ba0651d63c3c0155e0b2f48921cafd9527d9763
|
digest: sha256:08d589dd1b3386e8e8a2ac2c03a2194218ab12ed9e02016e7b981e554385dd11
|
||||||
generated: "2026-03-01T16:03:42.729194606Z"
|
generated: "2026-03-02T11:15:27.688786078Z"
|
||||||
|
|||||||
@@ -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.90
|
version: 0.58.3
|
||||||
appVersion: "0.64.4"
|
appVersion: "0.65.0"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -40,6 +40,6 @@ dependencies:
|
|||||||
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.46.0"
|
version: "1.47.0"
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
condition: ollama.enabled
|
condition: ollama.enabled
|
||||||
|
|||||||
@@ -235,24 +235,26 @@ async def get_server_status(request: Request) -> JSONResponse:
|
|||||||
if mode == AuthMode.MULTI_USER_BASIC:
|
if mode == AuthMode.MULTI_USER_BASIC:
|
||||||
response_data["supports_app_passwords"] = settings.enable_offline_access
|
response_data["supports_app_passwords"] = settings.enable_offline_access
|
||||||
|
|
||||||
# Include OIDC configuration if OAuth is available
|
# Include OIDC configuration for client discovery (e.g. Astrolabe PHP app).
|
||||||
# This includes OAuth mode AND hybrid mode (multi_user_basic + offline_access)
|
# Always attempt to provide oidc.discovery_url so clients can discover the
|
||||||
# Astrolabe needs OIDC config to discover IdP for OAuth flow in hybrid mode
|
# IdP regardless of the current auth mode. This enables smoother transitions
|
||||||
oauth_provisioning_available = auth_mode == "oauth" or (
|
# between auth modes and lets Astrolabe pre-discover OIDC endpoints.
|
||||||
mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access
|
oidc_config: dict[str, str] = {}
|
||||||
)
|
|
||||||
if oauth_provisioning_available:
|
|
||||||
# Provide IdP discovery information for NC PHP app
|
|
||||||
oidc_config = {}
|
|
||||||
|
|
||||||
if settings.oidc_discovery_url:
|
if settings.oidc_discovery_url:
|
||||||
oidc_config["discovery_url"] = settings.oidc_discovery_url
|
# Explicit OIDC_DISCOVERY_URL takes precedence
|
||||||
|
oidc_config["discovery_url"] = settings.oidc_discovery_url
|
||||||
|
elif settings.nextcloud_host:
|
||||||
|
# Auto-derive from NEXTCLOUD_HOST — Nextcloud exposes OIDC discovery
|
||||||
|
# at the standard well-known path when user_oidc is enabled
|
||||||
|
host = settings.nextcloud_host.rstrip("/")
|
||||||
|
oidc_config["discovery_url"] = f"{host}/.well-known/openid-configuration"
|
||||||
|
|
||||||
if settings.oidc_issuer:
|
if settings.oidc_issuer:
|
||||||
oidc_config["issuer"] = settings.oidc_issuer
|
oidc_config["issuer"] = settings.oidc_issuer
|
||||||
|
|
||||||
if oidc_config:
|
if oidc_config:
|
||||||
response_data["oidc"] = oidc_config
|
response_data["oidc"] = oidc_config
|
||||||
|
|
||||||
return JSONResponse(response_data)
|
return JSONResponse(response_data)
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ class ContactsClient(BaseNextcloudClient):
|
|||||||
"nickname": contact.nickname,
|
"nickname": contact.nickname,
|
||||||
"birthday": contact.bday,
|
"birthday": contact.bday,
|
||||||
"email": contact.email,
|
"email": contact.email,
|
||||||
|
"tel": contact.tel,
|
||||||
},
|
},
|
||||||
"addressdata": addressdata,
|
"addressdata": addressdata,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,23 +18,60 @@ from nextcloud_mcp_server.observability.metrics import instrument_tool
|
|||||||
logger = logging.getLogger(__name__)
|
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:
|
def _raw_contact_to_model(raw: dict) -> Contact:
|
||||||
"""Convert a raw contact dict from the contacts client to a Contact model.
|
"""Convert a raw contact dict from the contacts client to a Contact model.
|
||||||
|
|
||||||
Only maps fields the client's list_contacts() currently returns:
|
Maps fullname, nickname, birthday, email, and tel fields.
|
||||||
fullname, nickname, birthday, and email. Additional Contact model fields
|
Email/tel values may be plain strings, dicts with ``value``/``type`` keys,
|
||||||
(phones, addresses, organization, etc.) require expanding the client's
|
or lists of either – see :func:`_parse_vcard_fields`.
|
||||||
vCard parsing in ContactsClient.list_contacts().
|
|
||||||
"""
|
"""
|
||||||
contact_info = raw.get("contact", {})
|
contact_info = raw.get("contact", {})
|
||||||
|
|
||||||
# Convert email field (str, list, or None) to list[ContactField]
|
emails = _parse_vcard_fields(contact_info.get("email"), "email")
|
||||||
raw_email = contact_info.get("email")
|
phones = _parse_vcard_fields(contact_info.get("tel"), "phone")
|
||||||
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)
|
# Nickname goes into custom_fields (no dedicated model field)
|
||||||
custom_fields: dict[str, Any] = {}
|
custom_fields: dict[str, Any] = {}
|
||||||
@@ -48,6 +85,7 @@ def _raw_contact_to_model(raw: dict) -> Contact:
|
|||||||
etag=raw.get("getetag"),
|
etag=raw.get("getetag"),
|
||||||
birthday=contact_info.get("birthday"),
|
birthday=contact_info.get("birthday"),
|
||||||
emails=emails,
|
emails=emails,
|
||||||
|
phones=phones,
|
||||||
custom_fields=custom_fields,
|
custom_fields=custom_fields,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,7 +125,13 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
async def nc_contacts_list_contacts(
|
async def nc_contacts_list_contacts(
|
||||||
ctx: Context, *, addressbook: str
|
ctx: Context, *, addressbook: str
|
||||||
) -> ListContactsResponse:
|
) -> ListContactsResponse:
|
||||||
"""List all contacts in the specified addressbook."""
|
"""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)
|
client = await get_client(ctx)
|
||||||
contacts_data = 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]
|
contacts = [_raw_contact_to_model(c) for c in contacts_data]
|
||||||
@@ -140,7 +184,9 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
"""Create a new contact.
|
"""Create a new contact.
|
||||||
|
|
||||||
Args:
|
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.
|
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"}.
|
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
|
||||||
"""
|
"""
|
||||||
@@ -158,7 +204,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
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)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||||
|
|
||||||
@@ -174,7 +227,9 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
"""Update an existing contact while preserving all existing properties.
|
"""Update an existing contact while preserving all existing properties.
|
||||||
|
|
||||||
Args:
|
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.
|
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"}.
|
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.
|
etag: Optional ETag for optimistic concurrency control.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.64.4"
|
version = "0.65.0"
|
||||||
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"}
|
||||||
|
|||||||
+6
-2
@@ -7,14 +7,18 @@
|
|||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchPackageNames": ["pillow"],
|
"matchPackageNames": [
|
||||||
|
"pillow"
|
||||||
|
],
|
||||||
"allowedVersions": "<12.0.0"
|
"allowedVersions": "<12.0.0"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"customManagers": [
|
"customManagers": [
|
||||||
{
|
{
|
||||||
"customType": "regex",
|
"customType": "regex",
|
||||||
"fileMatch": ["^\\.github/workflows/test\\.yml$"],
|
"managerFilePatterns": [
|
||||||
|
"/^\\.github/workflows/test\\.yml$/"
|
||||||
|
],
|
||||||
"matchStrings": [
|
"matchStrings": [
|
||||||
"nextcloud_image:\\s*\"(?<depName>[^:]+):(?<currentValue>[^@]+)@(?<currentDigest>sha256:[a-f0-9]+)\""
|
"nextcloud_image:\\s*\"(?<depName>[^:]+):(?<currentValue>[^@]+)@(?<currentDigest>sha256:[a-f0-9]+)\""
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def create_mock_settings(
|
|||||||
oidc_issuer: str | None = None,
|
oidc_issuer: str | None = None,
|
||||||
vector_sync_enabled: bool = False,
|
vector_sync_enabled: bool = False,
|
||||||
nextcloud_url: str = "http://localhost",
|
nextcloud_url: str = "http://localhost",
|
||||||
|
nextcloud_host: str | None = "http://localhost",
|
||||||
enable_token_exchange: bool = False,
|
enable_token_exchange: bool = False,
|
||||||
mcp_client_id: str | None = None,
|
mcp_client_id: str | None = None,
|
||||||
mcp_client_secret: str | None = None,
|
mcp_client_secret: str | None = None,
|
||||||
@@ -49,6 +50,7 @@ def create_mock_settings(
|
|||||||
settings.oidc_issuer = oidc_issuer
|
settings.oidc_issuer = oidc_issuer
|
||||||
settings.vector_sync_enabled = vector_sync_enabled
|
settings.vector_sync_enabled = vector_sync_enabled
|
||||||
settings.nextcloud_url = nextcloud_url
|
settings.nextcloud_url = nextcloud_url
|
||||||
|
settings.nextcloud_host = nextcloud_host
|
||||||
settings.enable_token_exchange = enable_token_exchange
|
settings.enable_token_exchange = enable_token_exchange
|
||||||
settings.mcp_client_id = mcp_client_id
|
settings.mcp_client_id = mcp_client_id
|
||||||
settings.mcp_client_secret = mcp_client_secret
|
settings.mcp_client_secret = mcp_client_secret
|
||||||
@@ -133,6 +135,7 @@ class TestStatusEndpointOidcConfig:
|
|||||||
enable_offline_access=False, # Key difference: no offline access
|
enable_offline_access=False, # Key difference: no offline access
|
||||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||||
oidc_issuer="http://keycloak/realms/test",
|
oidc_issuer="http://keycloak/realms/test",
|
||||||
|
nextcloud_host=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -196,12 +199,13 @@ class TestStatusEndpointOidcConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_single_user_basic_no_oidc(self):
|
def test_single_user_basic_no_oidc(self):
|
||||||
"""Test that single-user BasicAuth mode doesn't return OIDC config."""
|
"""Test that single-user BasicAuth mode doesn't return OIDC config when no host."""
|
||||||
mock_settings = create_mock_settings(
|
mock_settings = create_mock_settings(
|
||||||
enable_multi_user_basic=False,
|
enable_multi_user_basic=False,
|
||||||
enable_offline_access=False,
|
enable_offline_access=False,
|
||||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||||
oidc_issuer="http://keycloak/realms/test",
|
oidc_issuer="http://keycloak/realms/test",
|
||||||
|
nextcloud_host=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -344,3 +348,127 @@ class TestStatusEndpointBasicResponse:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
assert data["vector_sync_enabled"] is True
|
assert data["vector_sync_enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusEndpointOidcAutoDerivation:
|
||||||
|
"""Tests for OIDC discovery_url auto-derivation from NEXTCLOUD_HOST."""
|
||||||
|
|
||||||
|
def test_derives_discovery_url_from_nextcloud_host(self):
|
||||||
|
"""Test that discovery_url is auto-derived from nextcloud_url when not explicit."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
oidc_discovery_url=None,
|
||||||
|
oidc_issuer=None,
|
||||||
|
)
|
||||||
|
mock_settings.nextcloud_host = "https://cloud.example.com"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
|
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "oidc" in data
|
||||||
|
assert (
|
||||||
|
data["oidc"]["discovery_url"]
|
||||||
|
== "https://cloud.example.com/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_derives_discovery_url_strips_trailing_slash(self):
|
||||||
|
"""Test that trailing slash on nextcloud_host is stripped."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
oidc_discovery_url=None,
|
||||||
|
oidc_issuer=None,
|
||||||
|
)
|
||||||
|
mock_settings.nextcloud_host = "https://cloud.example.com/"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
|
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "oidc" in data
|
||||||
|
assert (
|
||||||
|
data["oidc"]["discovery_url"]
|
||||||
|
== "https://cloud.example.com/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_discovery_url_takes_precedence(self):
|
||||||
|
"""Test that explicit OIDC_DISCOVERY_URL overrides auto-derivation."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
oidc_discovery_url="https://keycloak.example.com/.well-known/openid-configuration",
|
||||||
|
oidc_issuer=None,
|
||||||
|
)
|
||||||
|
mock_settings.nextcloud_host = "https://cloud.example.com"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
|
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "oidc" in data
|
||||||
|
assert (
|
||||||
|
data["oidc"]["discovery_url"]
|
||||||
|
== "https://keycloak.example.com/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_oidc_when_no_host_and_no_discovery_url(self):
|
||||||
|
"""Test that oidc block is absent when neither host nor discovery_url is set."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
oidc_discovery_url=None,
|
||||||
|
oidc_issuer=None,
|
||||||
|
)
|
||||||
|
mock_settings.nextcloud_host = None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
|
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "oidc" not in data
|
||||||
|
|||||||
@@ -376,6 +376,153 @@ def test_list_contacts_response_wraps_contacts():
|
|||||||
assert c["custom_fields"]["nickname"] == "Ali"
|
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 =============
|
# ============= _event_dict_to_summary tests =============
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1989,7 +1989,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.64.4"
|
version = "0.65.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user