diff --git a/.gitignore b/.gitignore
index 56429ff..0e7ee9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ docker-compose.override.yml
# Generated by pytest used to login users
.nextcloud_oauth_*.json
+.playwright-mcp/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abacc8a..0051506 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -86,7 +86,7 @@
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
- implement MCP sampling for semantic search RAG (ADR-008)
- add optional vector database and semantic search to helm chart
-- add vector sync processing status to /user/page endpoint
+- add vector sync processing status to /app endpoint
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
- implement vector sync scanner and processor (ADR-007 Phase 2)
diff --git a/docker-compose.yml b/docker-compose.yml
index 9161d32..6700d63 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -88,13 +88,13 @@ services:
- VECTOR_SYNC_SCAN_INTERVAL=10
- VECTOR_SYNC_PROCESSOR_WORKERS=1
- - LOG_FORMAT=text
+ #- LOG_FORMAT=json
# Qdrant configuration (three modes):
# 1. Network mode: Set QDRANT_URL=http://qdrant:6333 (requires qdrant service)
# 2. In-memory mode: Set QDRANT_LOCATION=:memory: (default if nothing set)
# 3. Persistent local: Set QDRANT_LOCATION=/app/data/qdrant (stored in mcp-data volume)
- - QDRANT_LOCATION=":memory:" # In-memory mode for CI/testing (no external service required)
+ #- QDRANT_LOCATION=/app/data/qdrant # In-memory mode used if not set
#- QDRANT_URL=http://qdrant:6333 # Uncomment for network mode
#- QDRANT_API_KEY=${QDRANT_API_KEY:-my_secret_api_key} # Only for network mode
diff --git a/docs/ADR-007-background-vector-sync-job-management.md b/docs/ADR-007-background-vector-sync-job-management.md
index b1fe052..0e7f817 100644
--- a/docs/ADR-007-background-vector-sync-job-management.md
+++ b/docs/ADR-007-background-vector-sync-job-management.md
@@ -377,7 +377,7 @@ async def get_vector_sync_status(ctx: Context) -> dict:
}
```
-The web UI (`/user/page` route) mirrors these controls with a simple toggle switch for enabling/disabling sync and a status display showing indexed counts and sync state. There is no job history, no detailed progress bars, no per-document status—just the essential information users need.
+The web UI (`/app` route) mirrors these controls with a simple toggle switch for enabling/disabling sync and a status display showing indexed counts and sync state. There is no job history, no detailed progress bars, no per-document status—just the essential information users need.
### Authentication and Offline Access
diff --git a/docs/ADR-010-webhook-based-vector-sync.md b/docs/ADR-010-webhook-based-vector-sync.md
index b021b09..cf8c91b 100644
--- a/docs/ADR-010-webhook-based-vector-sync.md
+++ b/docs/ADR-010-webhook-based-vector-sync.md
@@ -43,7 +43,8 @@ The webhook_listeners app supports events for all Nextcloud apps relevant to thi
**Files/Notes Events** (notes are stored as files):
- `OCP\Files\Events\Node\NodeCreatedEvent`
- `OCP\Files\Events\Node\NodeWrittenEvent`
-- `OCP\Files\Events\Node\NodeDeletedEvent`
+- `OCP\Files\Events\Node\BeforeNodeDeletedEvent` ⭐ **Use this for deletion (includes node.id)**
+- `OCP\Files\Events\Node\NodeDeletedEvent` (missing node.id - file already deleted)
- `OCP\Files\Events\Node\NodeRenamedEvent`
- `OCP\Files\Events\Node\NodeCopiedEvent`
@@ -228,8 +229,9 @@ def extract_document_task(event_class: str, payload: dict) -> DocumentTask | Non
modified_at=event_data["objectData"]["lastmodified"],
)
- # Deletion events
- elif "NodeDeletedEvent" in event_class or \
+ # Deletion events (use BeforeNodeDeletedEvent for files to get node.id)
+ elif "BeforeNodeDeletedEvent" in event_class or \
+ "NodeDeletedEvent" in event_class or \
"CalendarObjectDeletedEvent" in event_class:
# Similar logic for delete operations
...
@@ -412,9 +414,248 @@ async def test_webhook_integration_mocked_delivery():
**Deduplication Window**: Track recently processed documents (last 5 minutes) to avoid redundant work when webhooks and scanner both detect the same change. The processor can check a simple in-memory cache before fetching document content.
+## Appendix A: Manual Webhook Testing Results (2025-01-11)
+
+### Testing Summary
+
+Manual validation of Nextcloud webhook schemas and behavior confirmed that webhooks work as documented with several important findings for implementation. **5 out of 6** webhook types were successfully captured and validated.
+
+**Test Environment:**
+- Nextcloud 30+ (Docker compose)
+- webhook_listeners app enabled
+- Test endpoint: `http://mcp:8000/webhooks/nextcloud`
+- Background webhook worker running (60s timeout)
+
+**Results:**
+- ✅ NodeCreatedEvent (file creation)
+- ✅ NodeWrittenEvent (file update)
+- ✅ NodeDeletedEvent (file deletion)
+- ✅ CalendarObjectCreatedEvent
+- ✅ CalendarObjectUpdatedEvent
+- ❌ CalendarObjectDeletedEvent (webhook did not fire - potential Nextcloud bug)
+
+### Critical Implementation Findings
+
+#### 1. Deletion Events Lack `node.id` Field
+
+**Finding:** `NodeDeletedEvent` payloads do NOT include `event.node.id`, only `event.node.path`.
+
+**Example:**
+```json
+{
+ "user": {"uid": "admin", "displayName": "admin"},
+ "time": 1762851093,
+ "event": {
+ "class": "OCP\\Files\\Events\\Node\\NodeDeletedEvent",
+ "node": {
+ "path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
+ // NOTE: No "id" field present
+ }
+ }
+}
+```
+
+**Impact:** The event parser in this ADR's example code assumes `event_data["node"]["id"]` exists for all file events. This will fail for deletions.
+
+**Update (2025-11-11):** Nextcloud maintainer clarified that `BeforeNodeDeletedEvent` should be used instead of `NodeDeletedEvent` to access `node.id` before the file is deleted. See [issue #56371](https://github.com/nextcloud/server/issues/56371#issuecomment-2470896634).
+
+> "Try using the `BeforeNodeDeletedEvent`. The `id` should still be available at that time. The reason `id` is not in `NodeDeletedEvent` is because the file is effectively guaranteed to be gone and, in turn, so is the FileInfo."
+> — Josh Richards, Nextcloud maintainer
+
+**Recommended Solution:** Use `OCP\Files\Events\Node\BeforeNodeDeletedEvent` for file deletion webhooks instead of `NodeDeletedEvent`.
+
+**Alternative Fix (if using NodeDeletedEvent):** Check for `id` existence and fall back to path-based identification:
+
+```python
+def extract_document_task(event_class: str, payload: dict) -> DocumentTask | None:
+ user_id = payload["user"]["uid"]
+ event_data = payload["event"]
+
+ # File deletion events - NO node.id field
+ if "NodeDeletedEvent" in event_class:
+ path = event_data["node"]["path"]
+ if not path.endswith(".md"):
+ return None
+ # Use path-based ID since node.id is unavailable
+ return DocumentTask(
+ user_id=user_id,
+ doc_id=f"path:{path}", # Prefix to distinguish from numeric IDs
+ doc_type="note",
+ operation="delete",
+ modified_at=payload["time"],
+ )
+
+ # File creation/update events - node.id exists
+ elif "NodeCreatedEvent" in event_class or "NodeWrittenEvent" in event_class:
+ path = event_data["node"]["path"]
+ if not path.endswith(".md"):
+ return None
+
+ # Check if 'id' exists (should, but be defensive)
+ node_id = event_data["node"].get("id")
+ if not node_id:
+ # Fallback for missing ID
+ node_id = f"path:{path}"
+
+ return DocumentTask(
+ user_id=user_id,
+ doc_id=str(node_id),
+ doc_type="note",
+ operation="index",
+ modified_at=payload["time"],
+ )
+```
+
+**Qdrant Deletion Strategy:** When deleting by path-based ID, search Qdrant for documents with matching path metadata:
+
+```python
+async def delete_document_by_path(user_id: str, path: str):
+ """Delete document from Qdrant using path (when ID unavailable)."""
+ points = await qdrant.scroll(
+ collection_name=collection,
+ scroll_filter=Filter(must=[
+ FieldCondition(key="user_id", match=MatchValue(value=user_id)),
+ FieldCondition(key="metadata.path", match=MatchValue(value=path)),
+ ]),
+ )
+ # Delete found points...
+```
+
+#### 2. Multiple Webhooks Per Operation
+
+**Finding:** Creating a single note triggers 3-5 separate webhook events in rapid succession:
+
+1. `NodeCreatedEvent` for parent folder (if new)
+2. `NodeWrittenEvent` for parent folder
+3. `NodeCreatedEvent` for the note file
+4. `NodeWrittenEvent` for the note file (sometimes fires twice)
+
+**Impact:** Without deduplication, the processor will fetch and index the same note multiple times within seconds, wasting compute and API quota.
+
+**Solution:** The processor queue should be idempotent. If the same document is queued multiple times, only the latest version needs processing. Implementation options:
+
+1. **Queue-level deduplication:** Before adding to queue, check if a task for the same `(user_id, doc_id)` is already pending. Replace the existing task instead of adding duplicate.
+
+2. **Processor-level deduplication:** Track recently processed documents in a short-lived cache (5 minutes). If a document was just processed, skip redundant fetch unless the `modified_at` timestamp is newer.
+
+3. **Accept duplicates:** Let the processor handle duplicates naturally. Qdrant upserts are idempotent—reindexing with identical content is harmless but wasteful.
+
+**Recommendation:** Implement queue-level deduplication by maintaining a map of pending tasks and replacing duplicates with newer timestamps.
+
+#### 3. Type Discrepancy in `node.id`
+
+**Finding:** Nextcloud documentation specifies `node.id` as type `string`, but actual payloads return `int`:
+
+```json
+"node": {
+ "id": 437, // integer, not "437"
+ "path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
+}
+```
+
+**Impact:** Code that assumes `node.id` is always a string will work but may cause type confusion in strongly-typed languages.
+
+**Solution:** Explicitly convert to string when extracting: `doc_id=str(event_data["node"]["id"])`
+
+#### 4. Calendar Events Have Different ID Field Path
+
+**Finding:** Calendar events store the document ID in a different location than file events:
+
+- **File events:** `event.node.id`
+- **Calendar events:** `event.objectData.id`
+
+**Impact:** Event parser must handle different field paths for different event types. The example code in this ADR correctly shows this difference.
+
+**Calendar Event Deletion:** Calendar deletion webhooks did NOT fire during testing. This may be a Nextcloud bug or require specific configuration (e.g., trash bin enabled). Until resolved, calendar deletions will only be detected via periodic scanner runs.
+
+#### 5. Rich Metadata in Calendar Webhooks
+
+**Finding:** Calendar webhook payloads include extensive metadata not present in file webhooks:
+
+```json
+{
+ "event": {
+ "calendarId": 1,
+ "calendarData": {
+ "id": 1,
+ "uri": "personal",
+ "{http://calendarserver.org/ns/}getctag": "...",
+ "{http://sabredav.org/ns}sync-token": 21,
+ // ... many calendar-level properties
+ },
+ "objectData": {
+ "id": 3,
+ "uri": "webhook-test-event-001.ics",
+ "lastmodified": 1762851169,
+ "etag": "\"2b937b7d77dc83c77329dfdb210ba9d0\"",
+ "calendarid": 1,
+ "size": 297,
+ "component": "vevent",
+ "classification": 0,
+ "uid": "webhook-test-event-001@nextcloud",
+ "calendardata": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n...", // Full iCal
+ "{http://nextcloud.com/ns}deleted-at": null
+ },
+ "shares": [] // Array of sharing info
+ }
+}
+```
+
+**Opportunity:** The full iCal content is available in `objectData.calendardata`. The processor could extract metadata directly from the webhook payload instead of making an additional CalDAV request, reducing API load.
+
+### Updated Event Mapping
+
+Based on testing, the actual webhook behavior:
+
+| Nextcloud Event | Fires? | `node.id`/`objectData.id` Present? | Notes |
+|----------------|--------|-------------------------------------|-------|
+| `NodeCreatedEvent` | ✅ Yes | ✅ Yes (`int`) | Fires for folders too |
+| `NodeWrittenEvent` | ✅ Yes | ✅ Yes (`int`) | Fires 1-2x per operation |
+| `NodeDeletedEvent` | ✅ Yes | ❌ **NO** (only `path`) | Critical difference |
+| `CalendarObjectCreatedEvent` | ✅ Yes | ✅ Yes (`objectData.id`) | Full iCal included |
+| `CalendarObjectUpdatedEvent` | ✅ Yes | ✅ Yes (`objectData.id`) | Full iCal included |
+| `CalendarObjectDeletedEvent` | ❌ **DID NOT FIRE** | ❓ Unknown | Possible Nextcloud bug |
+
+### Recommended Implementation Changes
+
+The webhook handler code in this ADR requires these modifications:
+
+1. **Handle missing `node.id` in deletions** (see code example in Finding #1)
+2. **Add deduplication logic** to prevent redundant processing from multiple webhooks per operation
+3. **Validate field existence** before accessing nested properties (`get()` with defaults)
+4. **Log unsupported events** at DEBUG level (not WARNING) to avoid log noise
+5. **Add calendar deletion fallback:** Since webhook unreliable, calendar deletions rely on scanner reconciliation
+6. **Consider payload optimization:** Extract calendar metadata from webhook payload to reduce CalDAV API calls
+
+### Testing Implications
+
+**Integration Test Strategy:**
+
+The asynchronous nature of Nextcloud webhooks makes real webhook delivery unreliable for automated tests:
+
+- ✅ **DO:** POST webhook payloads directly to `/webhooks/nextcloud` endpoint in tests
+- ❌ **DON'T:** Trigger Nextcloud events and wait for webhook delivery
+- ✅ **DO:** Test authentication, payload parsing, and queue integration with mocked payloads
+- ❌ **DON'T:** Assume webhooks fire immediately or reliably
+
+**Manual Testing Required:**
+- Real webhook delivery latency (depends on background job workers)
+- Calendar deletion webhook behavior (confirm bug or configuration issue)
+- Behavior under high-frequency updates (bulk operations)
+- Network failure handling (Nextcloud can't reach MCP server)
+
+### Complete Tested Payload Examples
+
+See `webhook-testing-findings.md` in the repository root for:
+- Complete JSON payloads for all tested events
+- Detailed schema validation results
+- Additional edge cases and observations
+- Screenshots of webhook logs
+
## References
- ADR-007: Background Vector Database Synchronization (polling architecture)
- Nextcloud Documentation: `~/Software/documentation/admin_manual/webhook_listeners/index.rst`
- Nextcloud OCS API: Webhook registration endpoint
- Current scanner implementation: `nextcloud_mcp_server/vector/scanner.py:37`
+- Webhook Testing Report: `webhook-testing-findings.md` (2025-01-11)
diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py
index aeb36db..ecf6504 100644
--- a/nextcloud_mcp_server/app.py
+++ b/nextcloud_mcp_server/app.py
@@ -8,13 +8,12 @@ from typing import TYPE_CHECKING, Optional
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
if TYPE_CHECKING:
- from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
+ from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
import anyio
import click
import httpx
-import uvicorn
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP
@@ -22,7 +21,7 @@ from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.cors import CORSMiddleware
-from starlette.responses import JSONResponse
+from starlette.responses import JSONResponse, RedirectResponse
from starlette.routing import Mount, Route
from nextcloud_mcp_server.auth import (
@@ -42,7 +41,6 @@ from nextcloud_mcp_server.context import get_client as get_nextcloud_client
from nextcloud_mcp_server.document_processors import get_registry
from nextcloud_mcp_server.observability import (
ObservabilityMiddleware,
- get_uvicorn_logging_config,
setup_metrics,
setup_tracing,
)
@@ -219,6 +217,7 @@ class AppContext:
"""Application context for BasicAuth mode."""
client: NextcloudClient
+ storage: Optional["RefreshTokenStorage"] = None
document_send_stream: Optional[MemoryObjectSendStream] = None
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
shutdown_event: Optional[anyio.Event] = None
@@ -292,7 +291,7 @@ async def load_oauth_client_credentials(
# Try loading from SQLite storage
try:
- from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
+ from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
@@ -346,7 +345,7 @@ async def load_oauth_client_credentials(
# Ensure OAuth client in SQLite storage
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
- from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
+ from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
@@ -396,6 +395,13 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
client = NextcloudClient.from_env()
logger.info("Client initialization complete")
+ # Initialize persistent storage (for webhook tracking and future features)
+ from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
+
+ storage = RefreshTokenStorage.from_env()
+ await storage.initialize()
+ logger.info("Persistent storage initialized (webhook tracking enabled)")
+
# Initialize document processors
initialize_document_processors()
@@ -450,6 +456,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
try:
yield AppContext(
client=client,
+ storage=storage,
document_send_stream=send_stream,
document_receive_stream=receive_stream,
shutdown_event=shutdown_event,
@@ -466,7 +473,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
else:
# No vector sync - simple lifecycle
try:
- yield AppContext(client=client)
+ yield AppContext(client=client, storage=storage)
finally:
logger.info("Shutting down BasicAuth mode")
await client.close()
@@ -583,7 +590,7 @@ async def setup_oauth_config():
refresh_token_storage = None
if enable_offline_access:
try:
- from nextcloud_mcp_server.auth.refresh_token_storage import (
+ from nextcloud_mcp_server.auth.storage import (
RefreshTokenStorage,
)
@@ -1031,7 +1038,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# browser_app is in the same function scope (defined later in create_app)
# We need to find it in the mounted routes
for route in app.routes:
- if isinstance(route, Mount) and route.path == "/user":
+ if isinstance(route, Mount) and route.path == "/app":
route.app.state.oauth_context = oauth_context_dict
logger.info(
"OAuth context shared with browser_app for session auth"
@@ -1041,6 +1048,23 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
logger.info(
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
)
+ else:
+ # BasicAuth mode - share storage with browser_app for webhook management
+ from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
+
+ storage = RefreshTokenStorage.from_env()
+ await storage.initialize()
+
+ app.state.storage = storage
+
+ # Also share with browser_app for webhook routes
+ for route in app.routes:
+ if isinstance(route, Mount) and route.path == "/app":
+ route.app.state.storage = storage
+ logger.info(
+ "Storage shared with browser_app for webhook management"
+ )
+ break
# Start background vector sync tasks for BasicAuth mode (ADR-007)
# For streamable-http transport, FastMCP lifespan isn't automatically triggered
@@ -1075,15 +1099,15 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
app.state.shutdown_event = shutdown_event
app.state.scanner_wake_event = scanner_wake_event
- # Also share with browser_app for /user/page route
+ # Also share with browser_app for /app route
for route in app.routes:
- if isinstance(route, Mount) and route.path == "/user":
+ if isinstance(route, Mount) and route.path == "/app":
route.app.state.document_send_stream = send_stream
route.app.state.document_receive_stream = receive_stream
route.app.state.shutdown_event = shutdown_event
route.app.state.scanner_wake_event = scanner_wake_event
logger.info(
- "Vector sync state shared with browser_app for /user/page"
+ "Vector sync state shared with browser_app for /app"
)
break
@@ -1212,6 +1236,31 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
status_code=status_code,
)
+ async def handle_nextcloud_webhook(request):
+ """Test webhook endpoint to capture and log Nextcloud webhook payloads.
+
+ This is a temporary endpoint for testing webhook schemas and payloads.
+ It logs the full payload and returns 200 OK immediately.
+ """
+ import json
+
+ try:
+ payload = await request.json()
+ logger.info("=" * 80)
+ logger.info("🔔 Webhook received from Nextcloud:")
+ logger.info(json.dumps(payload, indent=2, sort_keys=True))
+ logger.info("=" * 80)
+
+ return JSONResponse(
+ {"status": "received", "timestamp": payload.get("time")},
+ status_code=200,
+ )
+ except Exception as e:
+ logger.error(f"❌ Failed to parse webhook payload: {e}")
+ return JSONResponse(
+ {"error": "invalid_payload", "message": str(e)}, status_code=400
+ )
+
# Add Protected Resource Metadata (PRM) endpoint for OAuth mode
routes = []
@@ -1220,6 +1269,12 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
routes.append(Route("/health/ready", health_ready, methods=["GET"]))
logger.info("Health check endpoints enabled: /health/live, /health/ready")
+ # Add test webhook endpoint (for development/testing)
+ routes.append(
+ Route("/webhooks/nextcloud", handle_nextcloud_webhook, methods=["POST"])
+ )
+ logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
+
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
@@ -1355,17 +1410,37 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
from nextcloud_mcp_server.auth.userinfo_routes import (
revoke_session,
user_info_html,
- user_info_json,
+ vector_sync_status_fragment,
+ )
+ from nextcloud_mcp_server.auth.webhook_routes import (
+ disable_webhook_preset,
+ enable_webhook_preset,
+ webhook_management_pane,
)
# Create a separate Starlette app for browser routes that need session auth
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
browser_routes = [
- Route("/", user_info_json, methods=["GET"]), # /user/ → user_info_json
- Route("/page", user_info_html, methods=["GET"]), # /user/page → user_info_html
+ Route("/", user_info_html, methods=["GET"]), # /app → webapp (HTML UI)
Route(
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
- ), # /user/revoke → revoke_session
+ ), # /app/revoke → revoke_session
+ # Vector sync status fragment (htmx polling)
+ Route(
+ "/vector-sync/status",
+ vector_sync_status_fragment,
+ methods=["GET"],
+ ), # /app/vector-sync/status
+ # Webhook management routes (admin-only)
+ Route("/webhooks", webhook_management_pane, methods=["GET"]), # /app/webhooks
+ Route(
+ "/webhooks/enable/{preset_id:str}", enable_webhook_preset, methods=["POST"]
+ ),
+ Route(
+ "/webhooks/disable/{preset_id:str}",
+ disable_webhook_preset,
+ methods=["DELETE"],
+ ),
]
browser_app = Starlette(routes=browser_routes)
@@ -1374,9 +1449,14 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
)
- # Mount browser app at /user (so /user and /user/page work)
- routes.append(Mount("/user", app=browser_app))
- logger.info("User info routes with session auth: /user, /user/page")
+ # Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
+ routes.append(
+ Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
+ )
+
+ # Mount browser app at /app (webapp and admin routes)
+ routes.append(Mount("/app", app=browser_app))
+ logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
routes.append(Mount("/", app=mcp_app))
@@ -1497,249 +1577,3 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
logger.info("WWW-Authenticate scope challenge handler enabled")
return app
-
-
-@click.command()
-@click.option(
- "--host", "-h", default="127.0.0.1", show_default=True, help="Server host"
-)
-@click.option(
- "--port", "-p", type=int, default=8000, show_default=True, help="Server port"
-)
-@click.option(
- "--log-level",
- "-l",
- default="info",
- show_default=True,
- type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
- help="Logging level",
-)
-@click.option(
- "--transport",
- "-t",
- default="sse",
- show_default=True,
- type=click.Choice(["sse", "streamable-http", "http"]),
- help="MCP transport protocol",
-)
-@click.option(
- "--enable-app",
- "-e",
- multiple=True,
- type=click.Choice(
- ["notes", "tables", "webdav", "calendar", "contacts", "cookbook", "deck"]
- ),
- help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
-)
-@click.option(
- "--oauth/--no-oauth",
- default=None,
- help="Force OAuth mode (if enabled) or BasicAuth mode (if disabled). By default, auto-detected based on environment variables.",
-)
-@click.option(
- "--oauth-client-id",
- envvar="NEXTCLOUD_OIDC_CLIENT_ID",
- help="OAuth client ID (can also use NEXTCLOUD_OIDC_CLIENT_ID env var)",
-)
-@click.option(
- "--oauth-client-secret",
- envvar="NEXTCLOUD_OIDC_CLIENT_SECRET",
- help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)",
-)
-@click.option(
- "--mcp-server-url",
- envvar="NEXTCLOUD_MCP_SERVER_URL",
- default="http://localhost:8000",
- show_default=True,
- help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)",
-)
-@click.option(
- "--nextcloud-host",
- envvar="NEXTCLOUD_HOST",
- help="Nextcloud instance URL (can also use NEXTCLOUD_HOST env var)",
-)
-@click.option(
- "--nextcloud-username",
- envvar="NEXTCLOUD_USERNAME",
- help="Nextcloud username for BasicAuth (can also use NEXTCLOUD_USERNAME env var)",
-)
-@click.option(
- "--nextcloud-password",
- envvar="NEXTCLOUD_PASSWORD",
- help="Nextcloud password for BasicAuth (can also use NEXTCLOUD_PASSWORD env var)",
-)
-@click.option(
- "--oauth-scopes",
- envvar="NEXTCLOUD_OIDC_SCOPES",
- default="openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
- show_default=True,
- help="OAuth scopes to request during client registration. These define the maximum allowed scopes for the client. Note: Actual supported scopes are discovered dynamically from MCP tools at runtime. (can also use NEXTCLOUD_OIDC_SCOPES env var)",
-)
-@click.option(
- "--oauth-token-type",
- envvar="NEXTCLOUD_OIDC_TOKEN_TYPE",
- default="bearer",
- show_default=True,
- type=click.Choice(["bearer", "jwt"], case_sensitive=False),
- help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)",
-)
-@click.option(
- "--public-issuer-url",
- envvar="NEXTCLOUD_PUBLIC_ISSUER_URL",
- help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)",
-)
-def run(
- host: str,
- port: int,
- log_level: str,
- transport: str,
- enable_app: tuple[str, ...],
- oauth: bool | None,
- oauth_client_id: str | None,
- oauth_client_secret: str | None,
- mcp_server_url: str,
- nextcloud_host: str | None,
- nextcloud_username: str | None,
- nextcloud_password: str | None,
- oauth_scopes: str,
- oauth_token_type: str,
- public_issuer_url: str | None,
-):
- """
- Run the Nextcloud MCP server.
-
- \b
- Authentication Modes:
- - BasicAuth: Set NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD
- - OAuth: Leave USERNAME/PASSWORD unset (requires OIDC app enabled)
-
- \b
- Examples:
- # BasicAuth mode with CLI options
- $ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com \\
- --nextcloud-username=admin --nextcloud-password=secret
-
- # BasicAuth mode with env vars (recommended for credentials)
- $ export NEXTCLOUD_HOST=https://cloud.example.com
- $ export NEXTCLOUD_USERNAME=admin
- $ export NEXTCLOUD_PASSWORD=secret
- $ nextcloud-mcp-server --host 0.0.0.0 --port 8000
-
- # OAuth mode with auto-registration
- $ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth
-
- # OAuth mode with pre-configured client
- $ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
- --oauth-client-id=xxx --oauth-client-secret=yyy
-
- # OAuth mode with custom scopes and JWT tokens
- $ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
- --oauth-scopes="openid notes:read notes:write" --oauth-token-type=jwt
-
- # OAuth with public issuer URL (for Docker/proxy setups)
- $ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
- --public-issuer-url=http://localhost:8080
- """
- # Set env vars from CLI options if provided
- if nextcloud_host:
- os.environ["NEXTCLOUD_HOST"] = nextcloud_host
- if nextcloud_username:
- os.environ["NEXTCLOUD_USERNAME"] = nextcloud_username
- if nextcloud_password:
- os.environ["NEXTCLOUD_PASSWORD"] = nextcloud_password
- if oauth_client_id:
- os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
- if oauth_client_secret:
- os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
- if oauth_scopes:
- os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
- if oauth_token_type:
- os.environ["NEXTCLOUD_OIDC_TOKEN_TYPE"] = oauth_token_type
- if mcp_server_url:
- os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
- if public_issuer_url:
- os.environ["NEXTCLOUD_PUBLIC_ISSUER_URL"] = public_issuer_url
-
- # Force OAuth mode if explicitly requested
- if oauth is True:
- # Clear username/password to force OAuth mode
- if "NEXTCLOUD_USERNAME" in os.environ:
- click.echo(
- "Warning: --oauth flag set, ignoring NEXTCLOUD_USERNAME", err=True
- )
- del os.environ["NEXTCLOUD_USERNAME"]
- if "NEXTCLOUD_PASSWORD" in os.environ:
- click.echo(
- "Warning: --oauth flag set, ignoring NEXTCLOUD_PASSWORD", err=True
- )
- del os.environ["NEXTCLOUD_PASSWORD"]
-
- # Validate OAuth configuration
- nextcloud_host = os.getenv("NEXTCLOUD_HOST")
- if not nextcloud_host:
- raise click.ClickException(
- "OAuth mode requires NEXTCLOUD_HOST environment variable to be set"
- )
-
- # Check if we have client credentials OR if dynamic registration is possible
- has_client_creds = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") and os.getenv(
- "NEXTCLOUD_OIDC_CLIENT_SECRET"
- )
-
- if not has_client_creds:
- # No client credentials - will attempt dynamic registration
- # Show helpful message before server starts
- click.echo("", err=True)
- click.echo("OAuth Configuration:", err=True)
- click.echo(" Mode: Dynamic Client Registration", err=True)
- click.echo(" Host: " + nextcloud_host, err=True)
- click.echo(" Storage: SQLite (TOKEN_STORAGE_DB)", err=True)
- click.echo("", err=True)
- click.echo(
- "Note: Make sure 'Dynamic Client Registration' is enabled", err=True
- )
- click.echo(" in your Nextcloud OIDC app settings.", err=True)
- click.echo("", err=True)
- else:
- click.echo("", err=True)
- click.echo("OAuth Configuration:", err=True)
- click.echo(" Mode: Pre-configured Client", err=True)
- click.echo(" Host: " + nextcloud_host, err=True)
- click.echo(
- " Client ID: "
- + os.getenv("NEXTCLOUD_OIDC_CLIENT_ID", "")[:16]
- + "...",
- err=True,
- )
- click.echo("", err=True)
-
- elif oauth is False:
- # Force BasicAuth mode - verify credentials exist
- if not os.getenv("NEXTCLOUD_USERNAME") or not os.getenv("NEXTCLOUD_PASSWORD"):
- raise click.ClickException(
- "--no-oauth flag set but NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD not set"
- )
-
- enabled_apps = list(enable_app) if enable_app else None
-
- app = get_app(transport=transport, enabled_apps=enabled_apps)
-
- # Get observability settings and create uvicorn logging config
- settings = get_settings()
- uvicorn_log_config = get_uvicorn_logging_config(
- log_format=settings.log_format,
- log_level=settings.log_level,
- include_trace_context=settings.log_include_trace_context,
- )
-
- uvicorn.run(
- app=app,
- host=host,
- port=port,
- log_level=log_level,
- log_config=uvicorn_log_config,
- )
-
-
-if __name__ == "__main__":
- run()
diff --git a/nextcloud_mcp_server/auth/browser_oauth_routes.py b/nextcloud_mcp_server/auth/browser_oauth_routes.py
index 34fb620..7bc2d00 100644
--- a/nextcloud_mcp_server/auth/browser_oauth_routes.py
+++ b/nextcloud_mcp_server/auth/browser_oauth_routes.py
@@ -1,7 +1,7 @@
"""Browser-based OAuth login routes for admin UI.
Separate from MCP OAuth flow - these routes establish browser sessions
-for accessing admin UI endpoints like /user/page.
+for accessing admin UI endpoints like /app.
"""
import hashlib
@@ -38,8 +38,8 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"""
oauth_ctx = request.app.state.oauth_context
if not oauth_ctx:
- # BasicAuth mode - no login needed, redirect to user page
- return RedirectResponse("/user/page", status_code=302)
+ # BasicAuth mode - no login needed, redirect to app
+ return RedirectResponse("/app", status_code=302)
storage = oauth_ctx["storage"]
oauth_client = oauth_ctx["oauth_client"]
@@ -71,7 +71,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
await storage.store_oauth_session(
session_id=state, # Use state as session ID
client_id="browser-ui",
- client_redirect_uri="/user/page",
+ client_redirect_uri="/app",
state=state,
code_challenge=code_challenge,
code_challenge_method="S256",
@@ -383,7 +383,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
# Continue anyway - profile cache is optional for browser UI
# Create response and set session cookie
- response = RedirectResponse("/user/page", status_code=302)
+ response = RedirectResponse("/app", status_code=302)
response.set_cookie(
key="mcp_session",
value=user_id,
diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py
index f4e3797..3931f31 100644
--- a/nextcloud_mcp_server/auth/client_registration.py
+++ b/nextcloud_mcp_server/auth/client_registration.py
@@ -8,7 +8,7 @@ from typing import Any
import anyio
import httpx
-from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
+from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
diff --git a/nextcloud_mcp_server/auth/oauth_routes.py b/nextcloud_mcp_server/auth/oauth_routes.py
index cf5fff8..35ae823 100644
--- a/nextcloud_mcp_server/auth/oauth_routes.py
+++ b/nextcloud_mcp_server/auth/oauth_routes.py
@@ -32,7 +32,7 @@ from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse
from nextcloud_mcp_server.auth.client_registry import get_client_registry
-from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
+from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
diff --git a/nextcloud_mcp_server/auth/permissions.py b/nextcloud_mcp_server/auth/permissions.py
new file mode 100644
index 0000000..551d201
--- /dev/null
+++ b/nextcloud_mcp_server/auth/permissions.py
@@ -0,0 +1,54 @@
+"""Permission checking utilities for Nextcloud admin operations."""
+
+import logging
+
+from httpx import AsyncClient
+from starlette.requests import Request
+
+from nextcloud_mcp_server.client.users import UsersClient
+
+logger = logging.getLogger(__name__)
+
+
+async def is_nextcloud_admin(request: Request, http_client: AsyncClient) -> bool:
+ """Check if the authenticated user is a Nextcloud administrator.
+
+ This function extracts the username from the session/request context
+ and checks if the user is a member of the "admin" group in Nextcloud.
+
+ Args:
+ request: Starlette request object with authenticated user
+ http_client: Authenticated HTTP client for Nextcloud API calls
+
+ Returns:
+ True if user is admin, False otherwise
+
+ Example:
+ ```python
+ if await is_nextcloud_admin(request, http_client):
+ # Show admin-only features
+ pass
+ ```
+ """
+ try:
+ # Extract username from authenticated session
+ username = request.user.display_name
+ if not username:
+ logger.warning("No username found in authenticated session")
+ return False
+
+ # Query Nextcloud for user's group memberships
+ users_client = UsersClient(http_client, username)
+ user_groups = await users_client.get_user_groups(username)
+
+ # Check if user is in the admin group
+ is_admin = "admin" in user_groups
+ logger.debug(
+ f"Admin check for user '{username}': {is_admin} (groups: {user_groups})"
+ )
+
+ return is_admin
+
+ except Exception as e:
+ logger.error(f"Error checking admin permissions: {e}", exc_info=True)
+ return False
diff --git a/nextcloud_mcp_server/auth/provisioning_decorator.py b/nextcloud_mcp_server/auth/provisioning_decorator.py
index b639331..52ad57a 100644
--- a/nextcloud_mcp_server/auth/provisioning_decorator.py
+++ b/nextcloud_mcp_server/auth/provisioning_decorator.py
@@ -13,7 +13,7 @@ from mcp.server.fastmcp import Context
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
-from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
+from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
diff --git a/nextcloud_mcp_server/auth/refresh_token_storage.py b/nextcloud_mcp_server/auth/storage.py
similarity index 81%
rename from nextcloud_mcp_server/auth/refresh_token_storage.py
rename to nextcloud_mcp_server/auth/storage.py
index 8d24682..ce3db76 100644
--- a/nextcloud_mcp_server/auth/refresh_token_storage.py
+++ b/nextcloud_mcp_server/auth/storage.py
@@ -1,23 +1,28 @@
"""
-Refresh Token Storage for ADR-002 Tier 1: Offline Access
+Persistent Storage for MCP Server State
-Manages two separate concerns for OAuth authentication:
+This module provides SQLite-based storage for multiple concerns across both
+BasicAuth and OAuth authentication modes:
-1. **Refresh Tokens** (for background jobs ONLY)
+1. **Refresh Tokens** (OAuth mode only, for background jobs)
- Securely stores encrypted refresh tokens for offline access
- Used ONLY by background jobs to obtain access tokens
- NEVER used within MCP client sessions or browser sessions
-2. **User Profile Cache** (for browser UI display ONLY)
+2. **User Profile Cache** (OAuth mode only, for browser UI display)
- Caches IdP user profile data for browser-based admin UI
- Queried ONCE at login, displayed from cache thereafter
- NOT used for authorization decisions or background jobs
-IMPORTANT: These are separate concerns. Browser sessions read profile cache for
-display purposes. Background jobs use refresh tokens for API access. Never mix
-the two.
+3. **Webhook Registration Tracking** (both modes, for webhook management)
+ - Tracks registered webhook IDs mapped to presets
+ - Enables persistent webhook state across restarts
+ - Avoids redundant Nextcloud API calls for webhook status
-Tokens are encrypted at rest using Fernet symmetric encryption.
+IMPORTANT: The database is initialized in both BasicAuth and OAuth modes.
+Token storage requires TOKEN_ENCRYPTION_KEY, but webhook tracking does not.
+
+Sensitive data (tokens, secrets) is encrypted at rest using Fernet symmetric encryption.
"""
import json
@@ -34,25 +39,34 @@ logger = logging.getLogger(__name__)
class RefreshTokenStorage:
- """Securely store and manage user refresh tokens and profile cache.
+ """Persistent storage for MCP server state (tokens, webhooks, and future features).
- This class manages two separate concerns:
- - Refresh tokens: Encrypted storage for background job access (write-only by OAuth, read-only by background jobs)
- - User profiles: Plain JSON cache for browser UI display (written at login, read by UI)
+ This class manages multiple concerns across both BasicAuth and OAuth modes:
- These concerns are architecturally separate and should never be mixed.
+ **OAuth-specific concerns**:
+ - Refresh tokens: Encrypted storage for background job access (requires encryption key)
+ - User profiles: Plain JSON cache for browser UI display
+ - OAuth client credentials: Encrypted client secrets from DCR
+ - OAuth sessions: Temporary session state for progressive consent flow
+
+ **Both modes**:
+ - Webhook registration: Track registered webhooks mapped to presets
+ - Schema versioning: Handle database migrations automatically
+
+ Token-related operations require TOKEN_ENCRYPTION_KEY, but webhook operations do not.
"""
- def __init__(self, db_path: str, encryption_key: bytes):
+ def __init__(self, db_path: str, encryption_key: bytes | None = None):
"""
- Initialize refresh token storage.
+ Initialize persistent storage.
Args:
db_path: Path to SQLite database file
- encryption_key: Fernet encryption key (32 bytes, base64-encoded)
+ encryption_key: Optional Fernet encryption key (32 bytes, base64-encoded).
+ Required for token storage operations, not required for webhook tracking.
"""
self.db_path = db_path
- self.cipher = Fernet(encryption_key)
+ self.cipher = Fernet(encryption_key) if encryption_key else None
self._initialized = False
@classmethod
@@ -62,41 +76,42 @@ class RefreshTokenStorage:
Environment variables:
TOKEN_STORAGE_DB: Path to database file (default: /app/data/tokens.db)
- TOKEN_ENCRYPTION_KEY: Base64-encoded Fernet key
+ TOKEN_ENCRYPTION_KEY: Optional base64-encoded Fernet key (required for token storage)
Returns:
RefreshTokenStorage instance
- Raises:
- ValueError: If TOKEN_ENCRYPTION_KEY is not set
+ Note:
+ If TOKEN_ENCRYPTION_KEY is not set, token storage operations will fail,
+ but webhook tracking will still work.
"""
db_path = os.getenv("TOKEN_STORAGE_DB", "/app/data/tokens.db")
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
- if not encryption_key_b64:
- raise ValueError(
- "TOKEN_ENCRYPTION_KEY environment variable is required. "
- "Generate one with: python -c 'from cryptography.fernet import Fernet; "
- "print(Fernet.generate_key().decode())'"
+ encryption_key = None
+ if encryption_key_b64:
+ # Fernet expects a base64url-encoded key as bytes, not decoded bytes
+ # The key from Fernet.generate_key() is already base64url-encoded
+ try:
+ # Convert string to bytes if needed
+ if isinstance(encryption_key_b64, str):
+ encryption_key = encryption_key_b64.encode()
+ else:
+ encryption_key = encryption_key_b64
+
+ # Validate the key by trying to create a Fernet instance
+ Fernet(encryption_key)
+ except Exception as e:
+ raise ValueError(
+ f"Invalid TOKEN_ENCRYPTION_KEY: {e}. "
+ "Must be a valid Fernet key (base64url-encoded 32 bytes)."
+ ) from e
+ else:
+ logger.info(
+ "TOKEN_ENCRYPTION_KEY not set - token storage operations will be unavailable, "
+ "but webhook tracking will still work"
)
- # Fernet expects a base64url-encoded key as bytes, not decoded bytes
- # The key from Fernet.generate_key() is already base64url-encoded
- try:
- # Convert string to bytes if needed
- if isinstance(encryption_key_b64, str):
- encryption_key = encryption_key_b64.encode()
- else:
- encryption_key = encryption_key_b64
-
- # Validate the key by trying to create a Fernet instance
- Fernet(encryption_key)
- except Exception as e:
- raise ValueError(
- f"Invalid TOKEN_ENCRYPTION_KEY: {e}. "
- "Must be a valid Fernet key (base64url-encoded 32 bytes)."
- ) from e
-
return cls(db_path=db_path, encryption_key=encryption_key)
async def initialize(self) -> None:
@@ -204,6 +219,38 @@ class RefreshTokenStorage:
"ON oauth_sessions(mcp_authorization_code)"
)
+ # Schema version tracking
+ await db.execute(
+ """
+ CREATE TABLE IF NOT EXISTS schema_version (
+ version INTEGER PRIMARY KEY,
+ applied_at REAL NOT NULL
+ )
+ """
+ )
+
+ # Registered webhooks tracking (both BasicAuth and OAuth modes)
+ await db.execute(
+ """
+ CREATE TABLE IF NOT EXISTS registered_webhooks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ webhook_id INTEGER NOT NULL UNIQUE,
+ preset_id TEXT NOT NULL,
+ created_at REAL NOT NULL
+ )
+ """
+ )
+
+ # Create indexes for efficient webhook queries
+ await db.execute(
+ "CREATE INDEX IF NOT EXISTS idx_webhooks_preset "
+ "ON registered_webhooks(preset_id)"
+ )
+ await db.execute(
+ "CREATE INDEX IF NOT EXISTS idx_webhooks_created "
+ "ON registered_webhooks(created_at)"
+ )
+
await db.commit()
# Set restrictive permissions after creation
@@ -1104,6 +1151,123 @@ class RefreshTokenStorage:
return deleted
+ # ============================================================================
+ # Webhook Registration Tracking (both BasicAuth and OAuth modes)
+ # ============================================================================
+
+ async def store_webhook(self, webhook_id: int, preset_id: str) -> None:
+ """
+ Store registered webhook ID for tracking.
+
+ Args:
+ webhook_id: Nextcloud webhook ID
+ preset_id: Preset identifier (e.g., "notes_sync", "calendar_sync")
+ """
+ if not self._initialized:
+ await self.initialize()
+
+ async with aiosqlite.connect(self.db_path) as db:
+ await db.execute(
+ "INSERT OR REPLACE INTO registered_webhooks (webhook_id, preset_id, created_at) VALUES (?, ?, ?)",
+ (webhook_id, preset_id, time.time()),
+ )
+ await db.commit()
+
+ logger.debug(f"Stored webhook {webhook_id} for preset '{preset_id}'")
+
+ async def get_webhooks_by_preset(self, preset_id: str) -> list[int]:
+ """
+ Get all webhook IDs registered for a preset.
+
+ Args:
+ preset_id: Preset identifier
+
+ Returns:
+ List of webhook IDs
+ """
+ if not self._initialized:
+ await self.initialize()
+
+ async with aiosqlite.connect(self.db_path) as db:
+ cursor = await db.execute(
+ "SELECT webhook_id FROM registered_webhooks WHERE preset_id = ?",
+ (preset_id,),
+ )
+ rows = await cursor.fetchall()
+
+ return [row[0] for row in rows]
+
+ async def delete_webhook(self, webhook_id: int) -> bool:
+ """
+ Remove webhook from tracking.
+
+ Args:
+ webhook_id: Nextcloud webhook ID to remove
+
+ Returns:
+ True if webhook was deleted, False if not found
+ """
+ if not self._initialized:
+ await self.initialize()
+
+ async with aiosqlite.connect(self.db_path) as db:
+ cursor = await db.execute(
+ "DELETE FROM registered_webhooks WHERE webhook_id = ?", (webhook_id,)
+ )
+ await db.commit()
+ deleted = cursor.rowcount > 0
+
+ if deleted:
+ logger.debug(f"Deleted webhook {webhook_id} from tracking")
+
+ return deleted
+
+ async def list_all_webhooks(self) -> list[dict]:
+ """
+ List all tracked webhooks with metadata.
+
+ Returns:
+ List of webhook dictionaries with keys: webhook_id, preset_id, created_at
+ """
+ if not self._initialized:
+ await self.initialize()
+
+ async with aiosqlite.connect(self.db_path) as db:
+ cursor = await db.execute(
+ "SELECT webhook_id, preset_id, created_at FROM registered_webhooks ORDER BY created_at DESC"
+ )
+ rows = await cursor.fetchall()
+
+ return [
+ {"webhook_id": row[0], "preset_id": row[1], "created_at": row[2]}
+ for row in rows
+ ]
+
+ async def clear_preset_webhooks(self, preset_id: str) -> int:
+ """
+ Delete all webhooks for a preset (bulk operation).
+
+ Args:
+ preset_id: Preset identifier
+
+ Returns:
+ Number of webhooks deleted
+ """
+ if not self._initialized:
+ await self.initialize()
+
+ async with aiosqlite.connect(self.db_path) as db:
+ cursor = await db.execute(
+ "DELETE FROM registered_webhooks WHERE preset_id = ?", (preset_id,)
+ )
+ await db.commit()
+ deleted = cursor.rowcount
+
+ if deleted > 0:
+ logger.debug(f"Cleared {deleted} webhook(s) for preset '{preset_id}'")
+
+ return deleted
+
async def generate_encryption_key() -> str:
"""
diff --git a/nextcloud_mcp_server/auth/token_broker.py b/nextcloud_mcp_server/auth/token_broker.py
index 152163c..a4d68aa 100644
--- a/nextcloud_mcp_server/auth/token_broker.py
+++ b/nextcloud_mcp_server/auth/token_broker.py
@@ -23,7 +23,7 @@ import httpx
import jwt
from cryptography.fernet import Fernet
-from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
+from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
logger = logging.getLogger(__name__)
diff --git a/nextcloud_mcp_server/auth/token_exchange.py b/nextcloud_mcp_server/auth/token_exchange.py
index 2ded73e..4ccc800 100644
--- a/nextcloud_mcp_server/auth/token_exchange.py
+++ b/nextcloud_mcp_server/auth/token_exchange.py
@@ -20,7 +20,7 @@ import httpx
import jwt
from ..config import get_settings
-from .refresh_token_storage import RefreshTokenStorage
+from .storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
diff --git a/nextcloud_mcp_server/auth/userinfo_routes.py b/nextcloud_mcp_server/auth/userinfo_routes.py
index 09a870c..9b9309e 100644
--- a/nextcloud_mcp_server/auth/userinfo_routes.py
+++ b/nextcloud_mcp_server/auth/userinfo_routes.py
@@ -19,6 +19,57 @@ from starlette.responses import HTMLResponse, JSONResponse
logger = logging.getLogger(__name__)
+async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.AsyncClient:
+ """Get an authenticated HTTP client for user info page operations.
+
+ Args:
+ request: Starlette request object
+
+ Returns:
+ Authenticated httpx.AsyncClient
+ """
+ oauth_ctx = getattr(request.app.state, "oauth_context", None)
+
+ # BasicAuth mode - use credentials from environment
+ if not oauth_ctx:
+ nextcloud_host = os.getenv("NEXTCLOUD_HOST")
+ username = os.getenv("NEXTCLOUD_USERNAME")
+ password = os.getenv("NEXTCLOUD_PASSWORD")
+
+ if not all([nextcloud_host, username, password]):
+ raise RuntimeError("BasicAuth credentials not configured")
+
+ assert nextcloud_host is not None # Type narrowing for type checker
+ return httpx.AsyncClient(
+ base_url=nextcloud_host,
+ auth=(username, password),
+ timeout=30.0,
+ )
+
+ # OAuth mode - get token from session
+ storage = oauth_ctx.get("storage")
+ session_id = request.cookies.get("mcp_session")
+
+ if not storage or not session_id:
+ raise RuntimeError("Session not found")
+
+ token_data = await storage.get_refresh_token(session_id)
+ if not token_data or "access_token" not in token_data:
+ raise RuntimeError("No access token found in session")
+
+ access_token = token_data["access_token"]
+ nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
+
+ if not nextcloud_host:
+ raise RuntimeError("Nextcloud host not configured")
+
+ return httpx.AsyncClient(
+ base_url=nextcloud_host,
+ headers={"Authorization": f"Bearer {access_token}"},
+ timeout=30.0,
+ )
+
+
async def _get_processing_status(request: Request) -> dict[str, Any] | None:
"""Get vector sync processing status.
@@ -88,6 +139,71 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
return None
+@requires("authenticated", redirect="oauth_login")
+async def vector_sync_status_fragment(request: Request) -> HTMLResponse:
+ """Vector sync status fragment endpoint - returns HTML fragment with current status.
+
+ This endpoint is polled by htmx to provide real-time updates of vector sync processing
+ status without requiring a full page refresh.
+
+ Requires authentication via session cookie (redirects to oauth_login route if not authenticated).
+
+ Args:
+ request: Starlette request object
+
+ Returns:
+ HTML response with vector sync status table fragment
+ """
+ processing_status = await _get_processing_status(request)
+
+ # If vector sync is disabled or unavailable, return empty fragment
+ if not processing_status:
+ return HTMLResponse(
+ """
+
+
Vector sync not available
+
+ """
+ )
+
+ indexed_count = processing_status["indexed_count"]
+ pending_count = processing_status["pending_count"]
+ status = processing_status["status"]
+
+ # Format numbers with commas for readability
+ indexed_count_str = f"{indexed_count:,}"
+ pending_count_str = f"{pending_count:,}"
+
+ # Status badge color and text
+ if status == "syncing":
+ status_badge = (
+ '⟳ Syncing'
+ )
+ else:
+ status_badge = '✓ Idle'
+
+ # Return inner content only (container div is in initial page render)
+ html = f"""
+
Vector Sync Status
+
+
+
Indexed Documents
+
{indexed_count_str}
+
+
+
Pending Documents
+
{pending_count_str}
+
+
+
Status
+
{status_badge}
+
+
+ """
+
+ return HTMLResponse(html)
+
+
async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
"""Get the correct userinfo endpoint based on OAuth mode.
@@ -296,6 +412,19 @@ async def user_info_html(request: Request) -> HTMLResponse:
# Get vector sync processing status
processing_status = await _get_processing_status(request)
+ # Check if user is admin (for Webhooks tab)
+ is_admin = False
+ try:
+ from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
+
+ # Get authenticated HTTP client
+ http_client = await _get_authenticated_client_for_userinfo(request)
+ is_admin = await is_nextcloud_admin(request, http_client)
+ await http_client.aclose()
+ except Exception as e:
+ logger.warning(f"Failed to check admin status: {e}")
+ # Default to not admin if check fails
+
# Check for error
if "error" in user_context and user_context["error"] != "":
# Get login URL dynamically
@@ -443,43 +572,15 @@ async def user_info_html(request: Request) -> HTMLResponse:
"""
- # Build vector sync status HTML
+ # Build vector sync status HTML (with htmx auto-refresh)
vector_status_html = ""
if processing_status:
- indexed_count = processing_status["indexed_count"]
- pending_count = processing_status["pending_count"]
- status = processing_status["status"]
-
- # Format numbers with commas for readability
- indexed_count_str = f"{indexed_count:,}"
- pending_count_str = f"{pending_count:,}"
-
- # Status badge color and text
- if status == "syncing":
- status_badge = (
- '⟳ Syncing'
- )
- else:
- status_badge = (
- '✓ Idle'
- )
-
- vector_status_html = f"""
-
Vector Sync Status
-
-
-
Indexed Documents
-
{indexed_count_str}
-
-
-
Pending Documents
-
{pending_count_str}
-
-
-
Status
-
{status_badge}
-
-
+ # Use htmx to load and auto-refresh the status fragment
+ # Container div stays stable, only inner content updates every 10s
+ vector_status_html = """
+