diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 9c99b2aa..f935fc56 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -2511,6 +2511,29 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = response = await call_next(request) return response + # Log the inbound User-Agent on management API and webhook receiver routes + # so we can tell which Astrolabe (or other PHP-side client) build is + # talking to the backend. Astrolabe sends ``Nextcloud-Astrolabe/``. + _UA_LOGGED_PATH_PREFIXES = ("/api/v1/", "/webhooks/nextcloud") + + @app.middleware("http") + async def log_client_user_agent(request, call_next): + path = request.url.path + if path.startswith(_UA_LOGGED_PATH_PREFIXES): + ua = request.headers.get("user-agent") or "(none)" + logger.info( + "%s %s from %s", + request.method, + path, + ua, + extra={ + "user_agent": ua, + "http_method": request.method, + "http_path": path, + }, + ) + return await call_next(request) + # Add CORS middleware to allow browser-based clients like MCP Inspector app.add_middleware( CORSMiddleware, # type: ignore[invalid-argument-type] diff --git a/nextcloud_mcp_server/server/webhook_presets.py b/nextcloud_mcp_server/server/webhook_presets.py index 11500a4e..2b435a86 100644 --- a/nextcloud_mcp_server/server/webhook_presets.py +++ b/nextcloud_mcp_server/server/webhook_presets.py @@ -43,11 +43,18 @@ TABLES_EVENT_ROW_DELETED = "OCA\\Tables\\Event\\RowDeletedEvent" # Forms webhook events (Nextcloud 30+) FORMS_EVENT_FORM_SUBMITTED = "OCA\\Forms\\Events\\FormSubmittedEvent" -# NOTE: Deck and Contacts do NOT support webhooks -# Their event classes do not implement IWebhookCompatibleEvent interface. -# Alternative sync strategies: -# - Deck: Use polling with ETag-based change detection -# - Contacts: Use CardDAV sync-token mechanism for efficient syncing +# Deck webhook events (require nextcloud/deck PR #7910, which adds +# IWebhookCompatibleEvent to these event classes). BoardUpdatedEvent only +# carries a board ID and is used as a fan-out signal; the polling scanner +# reconciles affected cards. +DECK_EVENT_CARD_CREATED = "OCA\\Deck\\Event\\CardCreatedEvent" +DECK_EVENT_CARD_UPDATED = "OCA\\Deck\\Event\\CardUpdatedEvent" +DECK_EVENT_CARD_DELETED = "OCA\\Deck\\Event\\CardDeletedEvent" +DECK_EVENT_BOARD_UPDATED = "OCA\\Deck\\Event\\BoardUpdatedEvent" + +# NOTE: Contacts does NOT support webhooks — its event classes do not +# implement IWebhookCompatibleEvent. Use CardDAV sync-token mechanism for +# efficient syncing. WEBHOOK_PRESETS: Dict[str, WebhookPreset] = { @@ -138,6 +145,29 @@ WEBHOOK_PRESETS: Dict[str, WebhookPreset] = { }, ], }, + "deck_sync": { + "name": "Deck Sync", + "description": "Real-time synchronization for Deck cards (create, update, delete) and board updates", + "app": "deck", + "events": [ + { + "event": DECK_EVENT_CARD_CREATED, + "filter": {}, + }, + { + "event": DECK_EVENT_CARD_UPDATED, + "filter": {}, + }, + { + "event": DECK_EVENT_CARD_DELETED, + "filter": {}, + }, + { + "event": DECK_EVENT_BOARD_UPDATED, + "filter": {}, + }, + ], + }, } diff --git a/nextcloud_mcp_server/vector/webhook_parser.py b/nextcloud_mcp_server/vector/webhook_parser.py index dfcb3710..40f079a8 100644 --- a/nextcloud_mcp_server/vector/webhook_parser.py +++ b/nextcloud_mcp_server/vector/webhook_parser.py @@ -4,8 +4,9 @@ Maps Nextcloud webhook events to vector-sync DocumentTasks. The handler at ``/webhooks/nextcloud`` calls :func:`extract_document_task` and forwards any non-None result to the same processor send-stream the scanner uses. -Currently scoped to file (note) events. Calendar / Tables events fall through -to ``None`` for now; those parsers can be added in follow-up changes. +Currently scoped to file (note) events and Deck card events. Calendar / +Tables events fall through to ``None`` for now; those parsers can be added +in follow-up changes. See ADR-010 for the design and ``webhook-testing-findings.md`` for real captured payloads. @@ -22,6 +23,19 @@ _FILE_EVENT_CREATED = "OCP\\Files\\Events\\Node\\NodeCreatedEvent" _FILE_EVENT_WRITTEN = "OCP\\Files\\Events\\Node\\NodeWrittenEvent" _FILE_EVENT_BEFORE_DELETED = "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent" +_DECK_EVENT_CARD_CREATED = "OCA\\Deck\\Event\\CardCreatedEvent" +_DECK_EVENT_CARD_UPDATED = "OCA\\Deck\\Event\\CardUpdatedEvent" +_DECK_EVENT_CARD_DELETED = "OCA\\Deck\\Event\\CardDeletedEvent" +_DECK_EVENT_BOARD_UPDATED = "OCA\\Deck\\Event\\BoardUpdatedEvent" + +_DECK_CARD_EVENTS = frozenset( + { + _DECK_EVENT_CARD_CREATED, + _DECK_EVENT_CARD_UPDATED, + _DECK_EVENT_CARD_DELETED, + } +) + # Matches paths inside any user's Notes folder ending in .md, e.g. # "/admin/files/Notes/Sub/Note.md" or "/alice/files/Notes/foo.md". _NOTES_PATH_RE = re.compile(r"^/[^/]+/files/Notes/.+\.md$") @@ -50,6 +64,9 @@ def extract_document_task(payload: dict) -> DocumentTask | None: ): return _parse_file_event(event_class, event, user_id, time) + if event_class in _DECK_CARD_EVENTS or event_class == _DECK_EVENT_BOARD_UPDATED: + return _parse_deck_event(event_class, event, user_id, time) + logger.debug("Ignoring webhook for unsupported event: %s", event_class) return None @@ -85,3 +102,46 @@ def _parse_file_event( operation=operation, modified_at=time, ) + + +def _parse_deck_event( + event_class: str, event: dict, user_id: str, time: int +) -> DocumentTask | None: + # BoardUpdatedEvent carries only ``boardId`` — there's no card identifier to + # index. Log delivery so we can confirm webhook arrival in the receiver + # logs, and let the polling scanner reconcile the affected cards. + if event_class == _DECK_EVENT_BOARD_UPDATED: + board_id = event.get("boardId") + logger.info( + "Deck board %s updated; polling scanner will reconcile cards", + board_id, + ) + return None + + card = event.get("card") or {} + card_id = card.get("id") + stack_id = card.get("stackId") + + if card_id is None: + # Without an id we can't address Qdrant points; the polling scanner + # will pick the change up on its next pass. + logger.warning( + "Webhook %s missing card.id; falling back to scanner", + event_class, + ) + return None + + operation = "delete" if event_class == _DECK_EVENT_CARD_DELETED else "index" + + # board_id is not part of the Card payload — processor.py falls back to + # iterating boards/stacks if it's missing, so we pass stack_id only. + metadata = {"stack_id": stack_id} if stack_id is not None else None + + return DocumentTask( + user_id=user_id, + doc_id=str(card_id), + doc_type="deck_card", + operation=operation, + modified_at=time, + metadata=metadata, + ) diff --git a/tests/unit/test_webhook_endpoint.py b/tests/unit/test_webhook_endpoint.py index cd1b05fb..800958bd 100644 --- a/tests/unit/test_webhook_endpoint.py +++ b/tests/unit/test_webhook_endpoint.py @@ -69,6 +69,68 @@ _NOTE_DELETED = { } +# Deck PR #7910 (CardCreatedEvent etc.) emits ``{"card": Card::jsonSerialize()}`` +# — see ~/Software/deck/lib/Event/ACardEvent.php. Card::jsonSerialize() includes +# id and stackId but not boardId (the processor falls back to iteration for +# that). BoardUpdatedEvent emits only ``{"boardId": int}``. +_DECK_CARD_CREATED = { + "user": {"uid": "admin"}, + "time": 1762900000, + "event": { + "class": "OCA\\Deck\\Event\\CardCreatedEvent", + "card": { + "id": 4242, + "title": "Webhook smoke test", + "stackId": 16, + }, + }, +} + + +_DECK_CARD_DELETED = { + "user": {"uid": "alice"}, + "time": 1762900100, + "event": { + "class": "OCA\\Deck\\Event\\CardDeletedEvent", + "card": { + "id": 4242, + "title": "Webhook smoke test", + "stackId": 16, + }, + }, +} + + +_DECK_BOARD_UPDATED = { + "user": {"uid": "admin"}, + "time": 1762900200, + "event": { + "class": "OCA\\Deck\\Event\\BoardUpdatedEvent", + "boardId": 5, + }, +} + + +_NOTE_CREATED_MISSING_ID = { + "user": {"uid": "admin"}, + "time": 1762850300, + "event": { + "class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent", + "node": {"path": "/admin/files/Notes/no-id.md"}, + }, +} + + +_DECK_CARD_CREATED_MISSING_ID = { + "user": {"uid": "admin"}, + "time": 1762900300, + "event": { + "class": "OCA\\Deck\\Event\\CardCreatedEvent", + "card": {"title": "Card without id", "stackId": 16}, + }, +} + + def test_index_event_queues_task_and_returns_200(): send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) app = _make_app(send_stream=send_stream) @@ -127,6 +189,93 @@ def test_unsupported_event_is_ignored(): receive_stream.receive_nowait() +def test_deck_card_created_queues_index_task(): + send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) + app = _make_app(send_stream=send_stream) + + with TestClient(app) as client: + response = client.post("/webhooks/nextcloud", json=_DECK_CARD_CREATED) + + assert response.status_code == 200 + assert response.json()["status"] == "queued" + assert response.json()["operation"] == "index" + assert response.json()["doc_id"] == "4242" + + task = receive_stream.receive_nowait() + assert task.user_id == "admin" + assert task.doc_id == "4242" + assert task.doc_type == "deck_card" + assert task.operation == "index" + assert task.metadata == {"stack_id": 16} + + +def test_deck_card_deleted_queues_delete_task(): + send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) + app = _make_app(send_stream=send_stream) + + with TestClient(app) as client: + response = client.post("/webhooks/nextcloud", json=_DECK_CARD_DELETED) + + assert response.status_code == 200 + assert response.json()["operation"] == "delete" + + task = receive_stream.receive_nowait() + assert task.doc_type == "deck_card" + assert task.operation == "delete" + assert task.doc_id == "4242" + assert task.user_id == "alice" + + +def test_deck_board_updated_is_ignored(): + """BoardUpdatedEvent carries no card id, so the parser logs delivery and + returns None — the polling scanner picks up the actual card changes.""" + send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) + app = _make_app(send_stream=send_stream) + + with TestClient(app) as client: + response = client.post("/webhooks/nextcloud", json=_DECK_BOARD_UPDATED) + + assert response.status_code == 200 + assert response.json()["status"] == "ignored" + + with pytest.raises(anyio.WouldBlock): + receive_stream.receive_nowait() + + +def test_deck_card_missing_id_is_ignored(): + """A card event without ``card.id`` can't address Qdrant points, so the + parser logs a warning and returns None — the polling scanner reconciles.""" + send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) + app = _make_app(send_stream=send_stream) + + with TestClient(app) as client: + response = client.post( + "/webhooks/nextcloud", json=_DECK_CARD_CREATED_MISSING_ID + ) + + assert response.status_code == 200 + assert response.json()["status"] == "ignored" + + with pytest.raises(anyio.WouldBlock): + receive_stream.receive_nowait() + + +def test_note_missing_node_id_is_ignored(): + """Symmetric coverage for the file-event fail-open branch: a notes path + without ``node.id`` falls back to the polling scanner.""" + send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) + app = _make_app(send_stream=send_stream) + + with TestClient(app) as client: + response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED_MISSING_ID) + + assert response.status_code == 200 + assert response.json()["status"] == "ignored" + + with pytest.raises(anyio.WouldBlock): + receive_stream.receive_nowait() + + def test_invalid_json_returns_400(): app = _make_app(send_stream=None) diff --git a/tests/unit/test_webhook_presets.py b/tests/unit/test_webhook_presets.py index 4e18b52d..248dd9c9 100644 --- a/tests/unit/test_webhook_presets.py +++ b/tests/unit/test_webhook_presets.py @@ -11,15 +11,16 @@ from nextcloud_mcp_server.server.webhook_presets import ( @pytest.mark.unit def test_list_all_presets(): - """Test listing all presets returns 5 presets.""" + """Test listing all presets returns the full preset catalogue.""" presets = list_presets() - assert len(presets) == 5 + assert len(presets) == 6 preset_ids = [preset_id for preset_id, _ in presets] assert "notes_sync" in preset_ids assert "calendar_sync" in preset_ids assert "tables_sync" in preset_ids assert "forms_sync" in preset_ids assert "files_sync" in preset_ids + assert "deck_sync" in preset_ids @pytest.mark.unit @@ -42,15 +43,50 @@ def test_get_preset_nonexistent(): @pytest.mark.unit def test_filter_presets_all_apps_installed(): """Test filtering when all apps are installed.""" - installed_apps = ["notes", "calendar", "tables", "forms"] + installed_apps = ["notes", "calendar", "tables", "forms", "deck"] filtered = filter_presets_by_installed_apps(installed_apps) - assert len(filtered) == 5 # All 5 presets (files is always included) + assert len(filtered) == 6 # All 6 presets (files is always included) preset_ids = [preset_id for preset_id, _ in filtered] assert "notes_sync" in preset_ids assert "calendar_sync" in preset_ids assert "tables_sync" in preset_ids assert "forms_sync" in preset_ids assert "files_sync" in preset_ids + assert "deck_sync" in preset_ids + + +@pytest.mark.unit +def test_filter_presets_deck_included_when_installed(): + """Test that the deck preset is included when the Deck app is installed.""" + installed_apps = ["deck"] + filtered = filter_presets_by_installed_apps(installed_apps) + preset_ids = [preset_id for preset_id, _ in filtered] + assert "deck_sync" in preset_ids + assert len(filtered) == 2 # deck + files + + +@pytest.mark.unit +def test_filter_presets_deck_excluded_when_not_installed(): + """Test that the deck preset is excluded when the Deck app is not installed.""" + installed_apps = ["notes", "calendar", "tables", "forms"] + filtered = filter_presets_by_installed_apps(installed_apps) + preset_ids = [preset_id for preset_id, _ in filtered] + assert "deck_sync" not in preset_ids + + +@pytest.mark.unit +def test_get_deck_preset(): + """Test getting the deck_sync preset returns the expected shape.""" + preset = get_preset("deck_sync") + assert preset is not None + assert preset["app"] == "deck" + assert preset["name"] == "Deck Sync" + assert len(preset["events"]) == 4 + event_classes = [e["event"] for e in preset["events"]] + assert "OCA\\Deck\\Event\\CardCreatedEvent" in event_classes + assert "OCA\\Deck\\Event\\CardUpdatedEvent" in event_classes + assert "OCA\\Deck\\Event\\CardDeletedEvent" in event_classes + assert "OCA\\Deck\\Event\\BoardUpdatedEvent" in event_classes @pytest.mark.unit