Compare commits

..

1 Commits

Author SHA1 Message Date
brandon 92f2d74637 feat: auto-derive oidc.discovery_url from NEXTCLOUD_HOST
Bump version / Bump version and create changelog for monorepo components (push) Failing after 8s
When OIDC_DISCOVERY_URL is not explicitly set, the status endpoint now
auto-derives the discovery URL from NEXTCLOUD_HOST using the standard
well-known path. This allows Astrolabe to discover OIDC endpoints
without requiring explicit OIDC configuration.

The oidc block is now included in the status response regardless of
auth mode when a discovery URL is available (explicit or derived),
enabling smoother auth mode transitions.

Closes #1
2026-03-29 12:56:50 -06:00
12 changed files with 308 additions and 544 deletions
+1 -11
View File
@@ -122,13 +122,6 @@ jobs:
npm ci
npm run build
- name: Build OIDC app
run: |
cd third_party/oidc
composer install --no-dev --optimize-autoloader
npm ci
npm run build
# Start services with the appropriate profile
- name: Run docker compose
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
@@ -206,9 +199,7 @@ jobs:
- name: Collect service logs on failure
if: failure()
run: |
docker compose --profile ${{ matrix.profile }} logs --tail=500 > /tmp/docker-compose-logs.txt 2>&1
docker compose exec -T app cat /var/www/html/data/nextcloud.log 2>/dev/null | tail -100 > /tmp/nextcloud-app.log 2>&1 || true
run: docker compose --profile ${{ matrix.profile }} logs --tail=500 > /tmp/docker-compose-logs.txt 2>&1
- name: Upload debug artifacts
if: failure()
@@ -218,6 +209,5 @@ jobs:
path: |
/tmp/*.png
/tmp/docker-compose-logs.txt
/tmp/nextcloud-app.log
retention-days: 7
if-no-files-found: ignore
+23 -3
View File
@@ -37,7 +37,7 @@ services:
# The post-installation hook will register /opt/apps as an additional app directory
#- ./third_party:/opt/apps:ro
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
- ./third_party/oidc:/opt/apps/oidc:ro
#- ./third_party/oidc:/opt/apps/oidc:ro # Use app store version; dev mount lacks vendor/
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -65,9 +65,9 @@ services:
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:ba6cb073af079c498e9466a5a9152ba4b6c9cad12efeeaf053ba383023d5db08
restart: always
ports:
- 127.0.0.1:8005:8000
- 127.0.0.1:8002:8000
# Unstructured API runs on port 8000 internally
# We expose it on 8005 externally to avoid conflict
# We expose it on 8002 externally to avoid conflict
profiles:
- unstructured
@@ -324,6 +324,26 @@ services:
profiles:
- login-flow
# Smithery stateless deployment mode (ADR-016)
# Test with: docker compose --profile smithery up smithery
# Then: curl http://localhost:8081/.well-known/mcp-config
smithery:
build:
context: .
dockerfile: Dockerfile.smithery
restart: always
depends_on:
app:
condition: service_healthy
ports:
- 127.0.0.1:8081:8081
environment:
- SMITHERY_DEPLOYMENT=true
- ENABLE_SEMANTIC_SEARCH=false
- PORT=8081
profiles:
- smithery
qdrant:
image: docker.io/qdrant/qdrant:v1.17.0@sha256:f1c7272cdac52b38c1a0e89313922d940ba50afd90d593a1605dbbc214e66ffb
restart: always
+17 -15
View File
@@ -235,24 +235,26 @@ async def get_server_status(request: Request) -> JSONResponse:
if mode == AuthMode.MULTI_USER_BASIC:
response_data["supports_app_passwords"] = settings.enable_offline_access
# Include OIDC configuration if OAuth is available
# This includes OAuth mode AND hybrid mode (multi_user_basic + offline_access)
# Astrolabe needs OIDC config to discover IdP for OAuth flow in hybrid mode
oauth_provisioning_available = auth_mode == "oauth" or (
mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access
)
if oauth_provisioning_available:
# Provide IdP discovery information for NC PHP app
oidc_config = {}
# Include OIDC configuration for client discovery (e.g. Astrolabe PHP app).
# Always attempt to provide oidc.discovery_url so clients can discover the
# IdP regardless of the current auth mode. This enables smoother transitions
# between auth modes and lets Astrolabe pre-discover OIDC endpoints.
oidc_config: dict[str, str] = {}
if settings.oidc_discovery_url:
oidc_config["discovery_url"] = settings.oidc_discovery_url
if 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:
oidc_config["issuer"] = settings.oidc_issuer
if settings.oidc_issuer:
oidc_config["issuer"] = settings.oidc_issuer
if oidc_config:
response_data["oidc"] = oidc_config
if oidc_config:
response_data["oidc"] = oidc_config
return JSONResponse(response_data)
+90 -132
View File
@@ -1,15 +1,14 @@
"""CalDAV client for Nextcloud calendar and task operations using caldav library."""
import datetime as dt
import inspect
import logging
import uuid
from typing import Any
from typing import Any, Dict, List, Optional
import anyio
from caldav.aio import AsyncCalendar, AsyncDAVClient, AsyncEvent
from caldav.async_collection import AsyncCalendar, AsyncEvent
from caldav.async_davclient import AsyncDAVClient
from caldav.elements import cdav, dav
from caldav.lib import error as caldav_error
from httpx import Auth
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
from icalendar import Event as ICalEvent
@@ -21,18 +20,6 @@ from ..config import get_nextcloud_ssl_verify
logger = logging.getLogger(__name__)
async def _maybe_await(result: Any) -> Any:
"""Await a result if it's a coroutine, otherwise return it directly.
caldav v3 uses dual-mode methods that return coroutines for async clients
but plain objects when the result is already available (e.g. load() on
already-loaded objects).
"""
if inspect.isawaitable(result):
return await result
return result
class CalendarClient:
"""Client for Nextcloud CalDAV calendar and task operations."""
@@ -51,7 +38,7 @@ class CalendarClient:
url=f"{base_url}/remote.php/dav/",
username=username,
auth=auth,
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to niquests which accepts SSLContext
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to httpx which accepts SSLContext
)
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
@@ -63,32 +50,9 @@ class CalendarClient:
"""Get an AsyncCalendar object for the given calendar name."""
calendar_url = self._get_calendar_url(calendar_name)
return AsyncCalendar(
client=self._dav_client, # type: ignore[arg-type] # AsyncDAVClient is valid for async mode
url=calendar_url,
name=calendar_name,
client=self._dav_client, url=calendar_url, name=calendar_name
)
async def _async_object_by_uid(
self, calendar: AsyncCalendar, uid: str, comp_filter: Any = None
) -> Any:
"""Async version of Calendar.get_object_by_uid.
Upstream caldav v3's get_object_by_uid is not async-aware: it calls
search() which returns a coroutine for async clients, then tries to
iterate the coroutine synchronously. This method properly awaits the
search result.
"""
# _hacks="insist" mirrors upstream's Calendar.get_object_by_uid pattern:
# retries with per-component-type searches if the initial search returns
# nothing, handling CalDAV servers with incomplete search support.
items_found = await calendar.search( # type: ignore[misc] # dual-mode: returns coroutine for async clients
uid=uid, xml=comp_filter, post_filter=True, _hacks="insist"
)
items_found = [o for o in items_found if o.id == uid]
if not items_found:
raise caldav_error.NotFoundError(f"{uid} not found on server")
return items_found[0]
async def close(self):
"""Close the DAV client connection."""
await self._dav_client.close()
@@ -136,7 +100,7 @@ class CalendarClient:
# ============= Calendar Operations =============
async def list_calendars(self) -> list[dict[str, Any]]:
async def list_calendars(self) -> List[Dict[str, Any]]:
"""List all available calendars for the user."""
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
@@ -153,9 +117,7 @@ class CalendarClient:
</d:propfind>"""
response = await self._dav_client.propfind(
self._calendar_home_url,
props=propfind_body, # type: ignore[arg-type] # props accepts XML body string
depth=1,
self._calendar_home_url, props=propfind_body, depth=1
)
result = []
@@ -227,7 +189,7 @@ class CalendarClient:
display_name: str = "",
description: str = "",
color: str = "#1976D2",
) -> dict[str, Any]:
) -> Dict[str, Any]:
"""Create a new calendar with retry on 429 errors."""
# Use custom MKCALENDAR XML instead of caldav library's make_calendar() due to:
# 1. Missing CalendarServer namespace (cs:) in caldav's nsmap
@@ -273,7 +235,7 @@ class CalendarClient:
"status_code": 201,
}
async def delete_calendar(self, calendar_name: str) -> dict[str, Any]:
async def delete_calendar(self, calendar_name: str) -> Dict[str, Any]:
"""Delete a calendar."""
# Use absolute URL for deletion
calendar_url = (
@@ -289,10 +251,10 @@ class CalendarClient:
async def get_calendar_events(
self,
calendar_name: str,
start_datetime: dt.datetime | None = None,
end_datetime: dt.datetime | None = None,
start_datetime: Optional[dt.datetime] = None,
end_datetime: Optional[dt.datetime] = None,
limit: int = 50,
) -> list[dict[str, Any]]:
) -> List[Dict[str, Any]]:
"""List events in a calendar within date range."""
calendar = self._get_calendar(calendar_name)
@@ -305,12 +267,12 @@ class CalendarClient:
expanded = bool(start_datetime and end_datetime)
else:
# No date filter — fetch all events
events = await calendar.events() # type: ignore[misc] # dual-mode
events = await calendar.events()
expanded = False
result = []
for event in events:
await _maybe_await(event.load(only_if_unloaded=True))
await event.load(only_if_unloaded=True)
if event.data:
if expanded:
# Server-side expansion: each response resource may contain
@@ -335,8 +297,8 @@ class CalendarClient:
async def _search_events_by_date(
self,
calendar: AsyncCalendar,
start_datetime: dt.datetime | None = None,
end_datetime: dt.datetime | None = None,
start_datetime: Optional[dt.datetime] = None,
end_datetime: Optional[dt.datetime] = None,
) -> list:
"""Execute a CalDAV REPORT with time-range filter."""
# Ensure naive datetimes are treated as UTC
@@ -364,7 +326,7 @@ class CalendarClient:
query.xmlelement(), encoding="utf-8", xml_declaration=True
)
assert calendar.client is not None
response = await calendar.client.report(str(calendar.url), body, depth=1) # type: ignore[misc] # dual-mode
response = await calendar.client.report(str(calendar.url), body, depth=1)
# Parse response (same pattern as AsyncCalendar.search)
objects = []
@@ -374,27 +336,27 @@ class CalendarClient:
continue
cal_data = props.get(cdav.CalendarData.tag)
if cal_data:
obj = AsyncEvent(
client=calendar.client,
url=calendar.url.join(href), # type: ignore[union-attr] # url is always set for calendars
data=cal_data,
parent=calendar,
)
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
objects.append(obj)
return objects
async def create_event(
self, calendar_name: str, event_data: dict[str, Any]
) -> dict[str, Any]:
self, calendar_name: str, event_data: Dict[str, Any]
) -> Dict[str, Any]:
"""Create a new calendar event."""
calendar = self._get_calendar(calendar_name)
event_uid = str(uuid.uuid4())
ical_content = self._create_ical_event(event_data, event_uid)
# caldav v3's _async_put raises PutError on HTTP failure
event = await calendar.save_event(ical=ical_content) # type: ignore[misc] # dual-mode
# save_event returns (event, response) tuple
event, response = await calendar.save_event(ical=ical_content)
if response.status not in [201, 204]:
raise RuntimeError(
f"Failed to create event {event_uid}: HTTP {response.status}"
)
logger.debug(f"Created event {event_uid}")
@@ -409,23 +371,21 @@ class CalendarClient:
self,
calendar_name: str,
event_uid: str,
event_data: dict[str, Any],
event_data: Dict[str, Any],
etag: str = "",
) -> dict[str, Any]:
) -> Dict[str, Any]:
"""Update an existing calendar event."""
calendar = self._get_calendar(calendar_name)
# Find the event by UID using caldav library
event = await self._async_object_by_uid(
calendar, event_uid, cdav.CompFilter("VEVENT")
)
await _maybe_await(event.load(only_if_unloaded=True))
event = await calendar.event_by_uid(event_uid)
await event.load(only_if_unloaded=True)
# Merge updates into existing iCal data
updated_ical = self._merge_ical_properties(event.data, event_data, event_uid) # type: ignore[arg-type]
event.data = updated_ical # type: ignore[misc]
await _maybe_await(event.save())
await event.save()
logger.debug(f"Updated event {event_uid}")
return {
@@ -435,31 +395,27 @@ class CalendarClient:
"status_code": 200,
}
async def delete_event(self, calendar_name: str, event_uid: str) -> dict[str, Any]:
async def delete_event(self, calendar_name: str, event_uid: str) -> Dict[str, Any]:
"""Delete a calendar event."""
calendar = self._get_calendar(calendar_name)
try:
event = await self._async_object_by_uid(
calendar, event_uid, cdav.CompFilter("VEVENT")
)
await _maybe_await(event.delete())
event = await calendar.event_by_uid(event_uid)
await event.delete()
logger.debug(f"Deleted event {event_uid}")
return {"status_code": 204}
except caldav_error.NotFoundError as e:
except Exception as e:
logger.debug(f"Event {event_uid} not found: {e}")
return {"status_code": 404}
async def get_event(
self, calendar_name: str, event_uid: str
) -> tuple[dict[str, Any], str]:
) -> tuple[Dict[str, Any], str]:
"""Get detailed information about a specific event."""
calendar = self._get_calendar(calendar_name)
event = await self._async_object_by_uid(
calendar, event_uid, cdav.CompFilter("VEVENT")
)
await _maybe_await(event.load(only_if_unloaded=True))
event = await calendar.event_by_uid(event_uid)
await event.load(only_if_unloaded=True)
event_data = self._parse_ical_event(event.data) if event.data else None # type: ignore[arg-type]
if not event_data:
@@ -473,10 +429,10 @@ class CalendarClient:
async def search_events_across_calendars(
self,
start_datetime: dt.datetime | None = None,
end_datetime: dt.datetime | None = None,
filters: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
start_datetime: Optional[dt.datetime] = None,
end_datetime: Optional[dt.datetime] = None,
filters: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""Search events across all calendars with advanced filtering."""
try:
calendars = await self.list_calendars()
@@ -515,19 +471,19 @@ class CalendarClient:
# ============= Todo/Task Operations (NEW) =============
async def list_todos(
self, calendar_name: str, filters: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
self, calendar_name: str, filters: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""List todos/tasks in a calendar."""
calendar = self._get_calendar(calendar_name)
# Get all todos including completed ones (filtering is done client-side)
todos = await calendar.todos(include_completed=True) # type: ignore[misc] # dual-mode
# Get all todos using caldav library (now with proper filter)
todos = await calendar.todos()
result = []
for todo in todos:
# Only load if data not already present from REPORT response
# This avoids 404 errors for virtual calendars (e.g., Deck boards)
await _maybe_await(todo.load(only_if_unloaded=True))
await todo.load(only_if_unloaded=True)
if todo.data:
todo_dict = self._parse_ical_todo(todo.data) # type: ignore[arg-type]
else:
@@ -544,16 +500,21 @@ class CalendarClient:
return result
async def create_todo(
self, calendar_name: str, todo_data: dict[str, Any]
) -> dict[str, Any]:
self, calendar_name: str, todo_data: Dict[str, Any]
) -> Dict[str, Any]:
"""Create a new todo/task."""
calendar = self._get_calendar(calendar_name)
todo_uid = str(uuid.uuid4())
ical_content = self._create_ical_todo(todo_data, todo_uid)
# caldav v3's _async_put raises PutError on HTTP failure
todo = await calendar.save_todo(ical=ical_content) # type: ignore[misc] # dual-mode
# save_todo returns (todo, response) tuple
todo, response = await calendar.save_todo(ical=ical_content)
if response.status not in [201, 204]:
raise RuntimeError(
f"Failed to create todo {todo_uid}: HTTP {response.status}"
)
logger.debug(f"Created todo {todo_uid}")
@@ -568,18 +529,16 @@ class CalendarClient:
self,
calendar_name: str,
todo_uid: str,
todo_data: dict[str, Any],
todo_data: Dict[str, Any],
etag: str = "",
) -> dict[str, Any]:
) -> Dict[str, Any]:
"""Update an existing todo/task."""
calendar = self._get_calendar(calendar_name)
try:
# Find the todo by UID
todo = await self._async_object_by_uid(
calendar, todo_uid, cdav.CompFilter("VTODO")
)
await _maybe_await(todo.load(only_if_unloaded=True))
todo = await calendar.todo_by_uid(todo_uid)
await todo.load(only_if_unloaded=True)
logger.debug(
f"Loaded todo {todo_uid}, current data length: {len(todo.data)}" # type: ignore
@@ -596,7 +555,8 @@ class CalendarClient:
todo.data = updated_ical
await _maybe_await(todo.save())
save_result = await todo.save()
logger.debug(f"Save result: {save_result}")
logger.debug(f"Updated todo {todo_uid}")
return {
@@ -609,24 +569,22 @@ class CalendarClient:
logger.error(f"Error updating todo {todo_uid}: {e}", exc_info=True)
raise
async def delete_todo(self, calendar_name: str, todo_uid: str) -> dict[str, Any]:
async def delete_todo(self, calendar_name: str, todo_uid: str) -> Dict[str, Any]:
"""Delete a todo/task."""
calendar = self._get_calendar(calendar_name)
try:
todo = await self._async_object_by_uid(
calendar, todo_uid, cdav.CompFilter("VTODO")
)
await _maybe_await(todo.delete())
todo = await calendar.todo_by_uid(todo_uid)
await todo.delete()
logger.debug(f"Deleted todo {todo_uid}")
return {"status_code": 204}
except caldav_error.NotFoundError as e:
except Exception as e:
logger.debug(f"Todo {todo_uid} not found: {e}")
return {"status_code": 404}
async def search_todos_across_calendars(
self, filters: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
self, filters: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""Search todos across all calendars."""
try:
calendars = await self.list_calendars()
@@ -658,7 +616,7 @@ class CalendarClient:
# ============= Helper Methods - Event iCalendar =============
def _create_ical_event(self, event_data: dict[str, Any], event_uid: str) -> str:
def _create_ical_event(self, event_data: Dict[str, Any], event_uid: str) -> str:
"""Create iCalendar content from event data."""
cal = Calendar()
cal.add("prodid", "-//Nextcloud MCP Server//EN")
@@ -742,12 +700,12 @@ class CalendarClient:
cal.add_component(event)
return cal.to_ical().decode("utf-8")
def _extract_vevent_data(self, component) -> dict[str, Any]:
def _extract_vevent_data(self, component) -> Dict[str, Any]:
"""Extract event data from a single VEVENT component.
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
"""
event_data: dict[str, Any] = {
event_data: Dict[str, Any] = {
"uid": str(component.get("uid", "")),
"title": str(component.get("summary", "")),
"description": str(component.get("description", "")),
@@ -800,7 +758,7 @@ class CalendarClient:
return event_data
def _parse_ical_event(self, ical_text: str) -> dict[str, Any] | None:
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
"""Parse iCalendar text and extract the first event."""
try:
cal = Calendar.from_ical(ical_text)
@@ -812,13 +770,13 @@ class CalendarClient:
logger.error(f"Error parsing iCalendar event: {e}")
return None
def _parse_all_ical_events(self, ical_text: str) -> list[dict[str, Any]]:
def _parse_all_ical_events(self, ical_text: str) -> list[Dict[str, Any]]:
"""Parse iCalendar text and extract ALL event occurrences.
Used with server-side expansion where a single VCALENDAR contains
multiple VEVENT components (one per recurrence occurrence).
"""
results: list[dict[str, Any]] = []
results: list[Dict[str, Any]] = []
try:
cal = Calendar.from_ical(ical_text)
for component in cal.walk():
@@ -829,7 +787,7 @@ class CalendarClient:
return results
def _merge_ical_properties(
self, raw_ical: str, event_data: dict[str, Any], event_uid: str
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
) -> str:
"""Merge new event data into existing raw iCal while preserving all properties."""
try:
@@ -965,7 +923,7 @@ class CalendarClient:
return parsed_dt
def _create_ical_todo(self, todo_data: dict[str, Any], todo_uid: str) -> str:
def _create_ical_todo(self, todo_data: Dict[str, Any], todo_uid: str) -> str:
"""Create iCalendar VTODO content from todo data."""
cal = Calendar()
cal.add("prodid", "-//Nextcloud MCP Server//EN")
@@ -1020,7 +978,7 @@ class CalendarClient:
cal.add_component(todo)
return cal.to_ical().decode("utf-8")
def _parse_ical_todo(self, ical_text: str) -> dict[str, Any] | None:
def _parse_ical_todo(self, ical_text: str) -> Optional[Dict[str, Any]]:
"""Parse iCalendar text and extract todo data."""
try:
cal = Calendar.from_ical(ical_text)
@@ -1064,7 +1022,7 @@ class CalendarClient:
return None
def _merge_ical_todo_properties(
self, raw_ical: str, todo_data: dict[str, Any], todo_uid: str
self, raw_ical: str, todo_data: Dict[str, Any], todo_uid: str
) -> str:
"""Merge new todo data into existing raw iCal while preserving all properties."""
try:
@@ -1170,15 +1128,15 @@ class CalendarClient:
return str(categories_obj)
def _apply_event_filters(
self, events: list[dict[str, Any]], filters: dict[str, Any]
) -> list[dict[str, Any]]:
self, events: List[Dict[str, Any]], filters: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Apply advanced filters to event list."""
return [
event for event in events if self._event_matches_filters(event, filters)
]
def _event_matches_filters(
self, event: dict[str, Any], filters: dict[str, Any]
self, event: Dict[str, Any], filters: Dict[str, Any]
) -> bool:
"""Check if an event matches the provided filters."""
try:
@@ -1221,7 +1179,7 @@ class CalendarClient:
return True
def _todo_matches_filters(
self, todo: dict[str, Any], filters: dict[str, Any]
self, todo: Dict[str, Any], filters: Dict[str, Any]
) -> bool:
"""Check if a todo matches the provided filters."""
try:
@@ -1258,8 +1216,8 @@ class CalendarClient:
# ============= Legacy Methods (for backward compatibility) =============
async def bulk_update_events(
self, filter_criteria: dict[str, Any], update_data: dict[str, Any]
) -> dict[str, Any]:
self, filter_criteria: Dict[str, Any], update_data: Dict[str, Any]
) -> Dict[str, Any]:
"""Bulk update events matching filter criteria."""
try:
start_datetime = None
@@ -1319,11 +1277,11 @@ class CalendarClient:
async def find_availability(
self,
duration_minutes: int,
attendees: list[str] | None = None,
start_datetime: dt.datetime | None = None,
end_datetime: dt.datetime | None = None,
constraints: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
attendees: Optional[List[str]] = None,
start_datetime: Optional[dt.datetime] = None,
end_datetime: Optional[dt.datetime] = None,
constraints: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""Find available time slots for scheduling.
Note: This is a simplified stub that returns empty list.
+2 -1
View File
@@ -17,7 +17,7 @@ dependencies = [
"pythonvcard4>=0.2.0",
"pydantic>=2.11.4",
"click>=8.1.8",
"caldav>=3.0.1,<4.0",
"caldav",
"pyjwt[crypto]>=2.8.0",
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
"alembic>=1.14.0", # Database migrations
@@ -114,6 +114,7 @@ extend-select = ["I", "PLC0415"]
"tests/**" = ["PLC0415"]
[tool.uv.sources]
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
[build-system]
@@ -639,10 +639,11 @@ async def test_calendar_operations_error_handling(
# Test with non-existent calendar
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
# caldav v3 raises NotFoundError for non-existent calendars
from caldav.lib.error import NotFoundError
with pytest.raises(NotFoundError):
await nc_client.calendar.get_calendar_events(fake_calendar)
# caldav library returns empty list for non-existent calendars, doesn't raise
# Testing that it doesn't crash and returns empty results
events = await nc_client.calendar.get_calendar_events(fake_calendar)
assert isinstance(events, list)
# Empty list is expected for non-existent calendar
assert len(events) == 0
logger.info("Error handling tests completed successfully")
@@ -10,8 +10,6 @@ from datetime import datetime, timedelta
import pytest
from nextcloud_mcp_server.client.calendar import _maybe_await
logger = logging.getLogger(__name__)
@@ -36,8 +34,8 @@ async def test_calendar_event_custom_fields_preservation(nc_client):
try:
# Get the calendar object from the caldav library
calendar = nc_client.calendar._get_calendar(calendar_name)
event = await nc_client.calendar._async_object_by_uid(calendar, event_uid)
await _maybe_await(event.load())
event = await calendar.event_by_uid(event_uid)
await event.load()
# Now manually inject custom iCal properties into the raw data
# This simulates what would happen if the event was created by another CalDAV client
@@ -308,8 +306,8 @@ async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
try:
# Get the calendar object and event
calendar = nc_client.calendar._get_calendar(calendar_name)
event = await nc_client.calendar._async_object_by_uid(calendar, event_uid)
await _maybe_await(event.load())
event = await calendar.event_by_uid(event_uid)
await event.load()
# Inject additional iCal properties that are valid but not supported by our parser
extended_ical = f"""BEGIN:VCALENDAR
@@ -350,6 +348,7 @@ END:VCALENDAR"""
# Confirm extended properties exist
extended_properties = [
"SEQUENCE:1",
"X-MICROSOFT-CDO-ALLDAYEVENT:FALSE",
"X-CUSTOM-MEETING-ID:12345-67890",
"X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890",
@@ -372,14 +371,9 @@ END:VCALENDAR"""
}
for prop in extended_properties:
if prop in flexible_patterns:
assert any(alt in original_ical for alt in flexible_patterns[prop]), (
f"Extended property {prop} (or alternatives) not found in original iCal"
)
else:
assert prop in original_ical, (
f"Extended property {prop} not found in original iCal"
)
assert prop in original_ical, (
f"Extended property {prop} not found in original iCal"
)
logger.info("✓ All extended properties confirmed in original iCal")
+27 -76
View File
@@ -382,34 +382,27 @@ async def nc_mcp_oauth_client_with_elicitation(
await page.wait_for_load_state("networkidle", timeout=60000)
logger.info(" ✓ Login completed")
# Wait for the OIDC redirect chain to settle before handling consent.
logger.info(" Waiting for OIDC redirect chain to settle...")
settle_start = time.time()
while time.time() - settle_start < 15:
current_url = page.url
if "/consent" in current_url or "/callback" in current_url:
break
await anyio.sleep(0.5)
# Handle consent screen if present
if "/consent" in page.url:
await page.wait_for_load_state("networkidle", timeout=10000)
try:
logger.info(f" Current URL before consent: {page.url}")
consent_handled = await _handle_oauth_consent_screen(page, username)
if consent_handled:
logger.info(" ✓ Consent granted")
else:
logger.warning(" ⚠ Consent handler returned False")
except Exception as e:
logger.warning(f" ⚠ Consent screen handling failed: {e}")
screenshot_path = (
f"/tmp/elicitation_consent_error_{uuid.uuid4()}.png"
)
try:
logger.info(f" Current URL before consent: {page.url}")
consent_handled = await _handle_oauth_consent_screen(page, username)
if consent_handled:
logger.info(" ✓ Consent granted")
else:
logger.warning(" ⚠ No consent screen detected")
# Take screenshot for debugging
screenshot_path = f"/tmp/elicitation_no_consent_{uuid.uuid4()}.png"
await page.screenshot(path=screenshot_path)
logger.info(f" Screenshot saved: {screenshot_path}")
else:
logger.debug(f" No consent screen (URL: {page.url})")
# Log page title for debugging
page_title = await page.title()
logger.info(f" Page title: {page_title}")
except Exception as e:
logger.warning(f" ⚠ Consent screen handling failed: {e}")
# Take screenshot for debugging
screenshot_path = f"/tmp/elicitation_consent_error_{uuid.uuid4()}.png"
await page.screenshot(path=screenshot_path)
logger.info(f" Screenshot saved: {screenshot_path}")
# Wait for OAuth callback URL to be reached
# The MCP server's callback endpoint will handle token exchange
@@ -1850,24 +1843,11 @@ async def playwright_oauth_token(
current_url = page.url
logger.info(f"After login, current URL: {current_url}")
# Wait for the OIDC redirect chain to settle before handling consent.
# After login, the flow goes: /apps/oidc/redirect (JS page) → JS navigates
# to /authorize → 303 to /consent. networkidle fires after the JS page
# loads but before the JS navigation starts.
logger.info("Waiting for OIDC redirect chain to settle...")
settle_start = time.time()
while time.time() - settle_start < 15:
current_url = page.url
if "/consent" in current_url or "localhost:8081" in current_url:
break
await anyio.sleep(0.5)
# Handle consent screen if present
if "/consent" in page.url:
await page.wait_for_load_state("networkidle", timeout=10000)
try:
await _handle_oauth_consent_screen(page, username)
else:
logger.debug(f"No consent screen (URL: {page.url})")
except Exception as e:
logger.debug(f"No consent screen or already authorized: {e}")
# Wait for callback server to receive the auth code
# Browser will be redirected to localhost:8081 which will capture the code
@@ -2158,21 +2138,11 @@ async def _get_oauth_token_with_scopes(
current_url = page.url
logger.info(f"After login, current URL: {current_url}")
# Wait for the OIDC redirect chain to settle before handling consent.
logger.info(f"Waiting for OIDC redirect chain to settle for {username}...")
settle_start = time.time()
while time.time() - settle_start < 15:
current_url = page.url
if "/consent" in current_url or "localhost:8081" in current_url:
break
await anyio.sleep(0.5)
# Handle consent screen if present
if "/consent" in page.url:
await page.wait_for_load_state("networkidle", timeout=10000)
try:
await _handle_oauth_consent_screen(page, username)
else:
logger.debug(f"No consent screen for {username} (URL: {page.url})")
except Exception as e:
logger.debug(f"No consent screen or already authorized: {e}")
# Wait for callback server to receive the auth code
logger.info(f"Waiting for auth code with state: {state[:16]}...")
@@ -2540,30 +2510,11 @@ async def _get_oauth_token_for_user(
await page.wait_for_load_state("networkidle", timeout=30000)
current_url = page.url
# Wait for the OIDC redirect chain to settle before handling consent.
# After login, the flow goes: /apps/oidc/redirect (JS page) → JS navigates
# to /authorize → 303 to /consent. networkidle fires after the JS page
# loads but before the JS navigation starts, so we must wait for the URL
# to reach either the consent page or the callback.
logger.info(f"Waiting for OIDC redirect chain to settle for {username}...")
settle_start = time.time()
while time.time() - settle_start < 15:
current_url = page.url
if "/consent" in current_url or "localhost:8081" in current_url:
break
await anyio.sleep(0.5)
else:
logger.warning(
f"OIDC redirect chain did not settle for {username}, "
f"current URL: {page.url}"
)
# Handle consent screen if present
if "/consent" in page.url:
await page.wait_for_load_state("networkidle", timeout=10000)
try:
await _handle_oauth_consent_screen(page, username)
else:
logger.debug(f"No consent screen for {username} (URL: {page.url})")
except Exception as e:
logger.debug(f"No consent screen or already authorized for {username}: {e}")
# Wait for callback server to receive the auth code
# Browser will be redirected to localhost:8081 which will capture the code
-78
View File
@@ -2,7 +2,6 @@
import json
import logging
import uuid
from datetime import datetime, timedelta
import pytest
@@ -468,80 +467,3 @@ async def test_mcp_todo_categories(
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
async def test_mcp_todo_href_mismatch(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test that todos with filename != UID are handled correctly (issue #629).
When a CalDAV object is stored with a filename different from its VTODO UID,
the server returns an href based on the filename. list_todos must return the
correct server-assigned href, and delete_todo must actually remove the todo.
"""
calendar_name = temporary_calendar
todo_uid = str(uuid.uuid4())
different_filename = str(uuid.uuid4())
# Build iCal content with a UID that differs from the filename
ical_content = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VTODO\r\n"
f"UID:{todo_uid}\r\n"
"SUMMARY:Href Mismatch Test\r\n"
"STATUS:NEEDS-ACTION\r\n"
"END:VTODO\r\n"
"END:VCALENDAR\r\n"
)
try:
# PUT the todo with a filename that differs from the UID
calendar = nc_client.calendar._get_calendar(calendar_name)
put_url = f"{calendar.url}{different_filename}.ics"
await calendar.client.put(
put_url,
ical_content,
{"Content-Type": "text/calendar; charset=utf-8"},
)
# list_todos via MCP should return href containing the filename, not the UID
list_result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name},
)
assert list_result.isError is False
list_data = json.loads(list_result.content[0].text)
our_todo = next((t for t in list_data["todos"] if t["uid"] == todo_uid), None)
assert our_todo is not None, f"Todo {todo_uid} not found in list_todos"
assert different_filename in our_todo["href"], (
f"Expected href to contain filename '{different_filename}', "
f"got '{our_todo['href']}'"
)
assert todo_uid not in our_todo["href"], (
f"href should NOT contain the UID '{todo_uid}', got '{our_todo['href']}'"
)
# delete_todo via MCP should actually remove the todo
delete_result = await nc_mcp_client.call_tool(
"nc_calendar_delete_todo",
{"calendar_name": calendar_name, "todo_uid": todo_uid},
)
assert delete_result.isError is False
# Verify it's really gone
todos = await nc_client.calendar.list_todos(calendar_name)
assert not any(t["uid"] == todo_uid for t in todos), (
"Todo should have been deleted but still exists"
)
logger.info("Todo href mismatch test passed")
finally:
# Cleanup in case of failure
try:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
+129 -1
View File
@@ -37,6 +37,7 @@ def create_mock_settings(
oidc_issuer: str | None = None,
vector_sync_enabled: bool = False,
nextcloud_url: str = "http://localhost",
nextcloud_host: str | None = "http://localhost",
enable_token_exchange: bool = False,
mcp_client_id: str | None = None,
mcp_client_secret: str | None = None,
@@ -49,6 +50,7 @@ def create_mock_settings(
settings.oidc_issuer = oidc_issuer
settings.vector_sync_enabled = vector_sync_enabled
settings.nextcloud_url = nextcloud_url
settings.nextcloud_host = nextcloud_host
settings.enable_token_exchange = enable_token_exchange
settings.mcp_client_id = mcp_client_id
settings.mcp_client_secret = mcp_client_secret
@@ -133,6 +135,7 @@ class TestStatusEndpointOidcConfig:
enable_offline_access=False, # Key difference: no offline access
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
oidc_issuer="http://keycloak/realms/test",
nextcloud_host=None,
)
with (
@@ -196,12 +199,13 @@ class TestStatusEndpointOidcConfig:
)
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(
enable_multi_user_basic=False,
enable_offline_access=False,
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
oidc_issuer="http://keycloak/realms/test",
nextcloud_host=None,
)
with (
@@ -344,3 +348,127 @@ class TestStatusEndpointBasicResponse:
data = response.json()
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
Generated
+4 -207
View File
File diff suppressed because one or more lines are too long