fix(webhooks): wire receiver to vector sync queue and fix registered URI

The /webhooks/nextcloud endpoint was a no-op stub that logged the
payload and returned 200 OK; webhook deletions never reached Qdrant.
Compounding that, _get_webhook_uri() registered the docker-compose
internal hostname (http://mcp:8000) with Nextcloud whenever
/.dockerenv existed — including ECS Fargate — so cloud deployments
were registering a URL NC could not resolve.

- New vector/webhook_parser.py extracts a DocumentTask from
  NodeCreatedEvent / NodeWrittenEvent / BeforeNodeDeletedEvent
  payloads scoped to */files/Notes/*.md (matching the registered
  preset filters).
- New vector/webhook_receiver.py pushes that task onto the same
  send-stream the scanner uses (app.state.document_send_stream),
  with 503 when sync is not running so NC retries delivery.
- _get_webhook_uri() now prefers NEXTCLOUD_MCP_SERVER_URL over the
  /.dockerenv branch, so the explicit public URL set on cloud tasks
  wins; docker-compose dev still falls back to the internal name when
  no public URL is configured.

Calendar / Tables event parsing is intentionally out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-30 03:13:50 +02:00
co-authored by Claude Opus 4.7
parent 111ae988f9
commit 2e2a098bee
7 changed files with 624 additions and 48 deletions
+24 -22
View File
@@ -77,33 +77,37 @@ async def _get_installed_apps(http_client: httpx.AsyncClient) -> list[str]:
def _get_webhook_uri() -> str:
"""Get the webhook endpoint URI for this MCP server.
This function determines the correct webhook URL based on the environment:
1. Uses WEBHOOK_INTERNAL_URL if explicitly set (highest priority)
2. Detects Docker environment and uses internal service name
3. Falls back to NEXTCLOUD_MCP_SERVER_URL
Priority (highest first):
1. ``WEBHOOK_INTERNAL_URL`` — explicit override, e.g. for split
internal/external URLs.
2. ``NEXTCLOUD_MCP_SERVER_URL`` — the configured public URL. This is
set on cloud deployments (ECS, k8s) and is the URL Nextcloud must
POST to.
3. ``/.dockerenv`` (or podman / ``DOCKER_CONTAINER=true``) → internal
docker-compose service name. Only relevant when no public URL is
configured — i.e. local dev where MCP and NC share a Docker
network.
4. ``http://localhost:8000`` — last-resort fallback.
In Docker environments, Nextcloud needs to reach the MCP service using
the internal Docker network hostname (e.g., http://mcp:8000), not localhost.
Returns:
Full webhook endpoint URL accessible from Nextcloud
Note: ECS Fargate containers also expose ``/.dockerenv``. Without this
priority order, cloud deployments would silently register an internal
docker-compose hostname (e.g. ``http://mcp:8000``) that NC cannot
resolve, dropping every webhook delivery.
"""
# Explicit override (highest priority)
webhook_url = os.getenv("WEBHOOK_INTERNAL_URL")
if webhook_url:
return f"{webhook_url}/webhooks/nextcloud"
# Detect Docker environment
# Check for common Docker indicators
is_docker = (
os.path.exists("/.dockerenv") # Docker container marker file
or os.path.exists("/run/.containerenv") # Podman marker
or os.getenv("DOCKER_CONTAINER") == "true" # Explicit flag
)
server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL")
if server_url:
return f"{server_url}/webhooks/nextcloud"
is_docker = (
os.path.exists("/.dockerenv")
or os.path.exists("/run/.containerenv")
or os.getenv("DOCKER_CONTAINER") == "true"
)
if is_docker:
# In Docker, use internal service name from NEXTCLOUD_MCP_SERVICE_NAME
# or default to 'mcp' (docker-compose service name)
service_name = os.getenv("NEXTCLOUD_MCP_SERVICE_NAME", "mcp")
port = os.getenv("NEXTCLOUD_MCP_PORT", "8000")
logger.debug(
@@ -111,9 +115,7 @@ def _get_webhook_uri() -> str:
)
return f"http://{service_name}:{port}/webhooks/nextcloud"
# Fallback to configured server URL (for non-Docker deployments)
server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
return f"{server_url}/webhooks/nextcloud"
return "http://localhost:8000/webhooks/nextcloud"
async def _get_authenticated_client(request: Request) -> httpx.AsyncClient: