Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions nextcloud_mcp_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2475,6 +2475,29 @@ async def log_auth_headers(request, call_next):
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/<version>``.
_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]
Expand Down
40 changes: 35 additions & 5 deletions nextcloud_mcp_server/server/webhook_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,18 @@ class WebhookPreset(TypedDict):
# 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] = {
Expand Down Expand Up @@ -138,6 +145,29 @@ class WebhookPreset(TypedDict):
},
],
},
"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": {},
},
],
},
}


Expand Down
64 changes: 62 additions & 2 deletions nextcloud_mcp_server/vector/webhook_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
``/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.
Expand All @@ -22,6 +23,19 @@
_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$")
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
)
149 changes: 149 additions & 0 deletions tests/unit/test_webhook_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,68 @@ def _make_app(send_stream=None) -> Starlette:
}


# 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)
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading