Skip to main content
Glama
cbcoutinho

Nextcloud MCP Server

by cbcoutinho
webhook_routes.py20.1 kB
"""Webhook management routes for admin UI. Provides browser-based endpoints for admin users to manage webhook configurations using preset templates. Only accessible to Nextcloud administrators. """ import logging import os import httpx from starlette.authentication import requires from starlette.requests import Request from starlette.responses import HTMLResponse from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin from nextcloud_mcp_server.client.webhooks import WebhooksClient from nextcloud_mcp_server.server.webhook_presets import ( WEBHOOK_PRESETS, filter_presets_by_installed_apps, get_preset, ) logger = logging.getLogger(__name__) def _get_storage(request: Request): """Get storage instance from app state. Args: request: Starlette request object Returns: RefreshTokenStorage instance or None """ # Try browser_app state first (for /app routes) storage = getattr(request.app.state, "storage", None) # Try oauth_context if in OAuth mode if not storage: oauth_ctx = getattr(request.app.state, "oauth_context", None) if oauth_ctx: storage = oauth_ctx.get("storage") return storage async def _get_installed_apps(http_client: httpx.AsyncClient) -> list[str]: """Get list of installed and enabled apps from Nextcloud capabilities. Args: http_client: Authenticated HTTP client Returns: List of installed app names (e.g., ["notes", "calendar", "forms"]) """ try: response = await http_client.get( "/ocs/v2.php/cloud/capabilities", headers={"OCS-APIRequest": "true", "Accept": "application/json"}, ) response.raise_for_status() data = response.json() # Extract app names from capabilities capabilities = data.get("ocs", {}).get("data", {}).get("capabilities", {}) # Filter out core NC capabilities (not apps) core_keys = {"version", "core"} app_keys = set(capabilities.keys()) - core_keys return sorted(app_keys) except Exception as e: logger.warning(f"Failed to get installed apps from capabilities: {e}") return [] 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 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 """ # 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 ) 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( f"Docker environment detected, using internal URL: http://{service_name}:{port}" ) 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" async def _get_authenticated_client(request: Request) -> httpx.AsyncClient: """Get an authenticated HTTP client for Nextcloud API calls. Args: request: Starlette request object Returns: Authenticated httpx.AsyncClient Raises: RuntimeError: If unable to create authenticated client """ # Get OAuth context from app state 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 assert username is not None and password is not None # Type narrowing 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_enabled_presets( webhooks_client: WebhooksClient, storage=None, ) -> dict[str, list[int]]: """Get currently enabled webhook presets. Reads from database first for better performance. Falls back to API if needed. Args: webhooks_client: Webhooks API client storage: Optional RefreshTokenStorage instance Returns: Dictionary mapping preset_id to list of webhook IDs """ try: # Try database first (faster, works offline) if storage: all_webhooks = await storage.list_all_webhooks() enabled_presets: dict[str, list[int]] = {} for webhook in all_webhooks: preset_id = webhook["preset_id"] webhook_id = webhook["webhook_id"] if preset_id not in enabled_presets: enabled_presets[preset_id] = [] enabled_presets[preset_id].append(webhook_id) return enabled_presets # Fallback to API query registered_webhooks = await webhooks_client.list_webhooks() webhook_uri = _get_webhook_uri() # Group webhooks by preset based on matching events enabled_presets: dict[str, list[int]] = {} for preset_id, preset in WEBHOOK_PRESETS.items(): preset_event_classes = {event["event"] for event in preset["events"]} matching_webhooks = [] for webhook in registered_webhooks: # Check if webhook matches this preset if ( webhook.get("uri") == webhook_uri and webhook.get("event") in preset_event_classes ): matching_webhooks.append(webhook["id"]) if matching_webhooks: enabled_presets[preset_id] = matching_webhooks return enabled_presets except Exception as e: logger.error(f"Failed to list webhooks: {e}") return {} @requires("authenticated", redirect="oauth_login") async def webhook_management_pane(request: Request) -> HTMLResponse: """Webhook management pane - returns HTML for webhook configuration. This endpoint checks if the user is an admin and returns either: - Admin view: Webhook management interface with preset controls - Non-admin view: Message indicating admin-only access Args: request: Starlette request object Returns: HTML response with webhook management interface or access denied message """ try: # Get authenticated HTTP client http_client = await _get_authenticated_client(request) username = request.user.display_name # Check admin permissions is_admin = await is_nextcloud_admin(request, http_client) if not is_admin: return HTMLResponse( content=""" <div class="info-message"> <p><strong>Admin Access Required</strong></p> <p>Webhook management is only available to Nextcloud administrators.</p> <p>Your account does not have admin privileges.</p> </div> """ ) # Get webhooks client webhooks_client = WebhooksClient(http_client, username) # Get storage for database-backed webhook tracking storage = _get_storage(request) # Get installed apps to filter presets installed_apps = await _get_installed_apps(http_client) logger.debug(f"Installed apps: {installed_apps}") # Get currently enabled presets (from database or API) enabled_presets = await _get_enabled_presets(webhooks_client, storage) # Filter presets based on installed apps available_presets = filter_presets_by_installed_apps(installed_apps) # Build preset cards HTML preset_cards_html = "" for preset_id, preset in available_presets: is_enabled = preset_id in enabled_presets num_webhooks = len(enabled_presets.get(preset_id, [])) # Status badge if is_enabled: status_badge = f'<span style="color: #4caf50; font-weight: bold;">✓ Enabled ({num_webhooks} webhooks)</span>' action_button = f""" <button hx-delete="/app/webhooks/disable/{preset_id}" hx-target="#preset-{preset_id}" hx-swap="outerHTML" class="button" style="background-color: #ff9800;"> Disable </button> """ else: status_badge = '<span style="color: #999;">Not Enabled</span>' action_button = f""" <button hx-post="/app/webhooks/enable/{preset_id}" hx-target="#preset-{preset_id}" hx-swap="outerHTML" class="button button-primary"> Enable </button> """ preset_cards_html += f""" <div id="preset-{preset_id}" style="border: 1px solid #e0e0e0; border-radius: 6px; padding: 20px; margin: 15px 0;"> <h3 style="margin-top: 0; color: #0082c9;">{preset["name"]}</h3> <p style="color: #666; margin: 10px 0;">{preset["description"]}</p> <p style="font-size: 13px; color: #999;"> <strong>App:</strong> {preset["app"]} | <strong>Events:</strong> {len(preset["events"])} </p> <div style="margin-top: 15px; display: flex; align-items: center; gap: 15px;"> <div>{status_badge}</div> <div>{action_button}</div> </div> </div> """ # Get webhook endpoint URL for display webhook_uri = _get_webhook_uri() html_content = f""" <h2>Webhook Management</h2> <div class="info-message"> <p><strong>About Webhooks</strong></p> <p>Webhooks enable real-time synchronization by notifying this server when content changes in Nextcloud.</p> <p><strong>Endpoint:</strong> <code>{webhook_uri}</code></p> </div> <h3 style="margin-top: 30px;">Available Presets</h3> <p style="color: #666;">Enable webhook presets with one click for common synchronization scenarios.</p> <p style="color: #999; font-size: 13px; margin-top: 5px;">Showing {len(available_presets)} preset(s) for your installed apps ({len(installed_apps)} detected)</p> {preset_cards_html} """ return HTMLResponse(content=html_content) except Exception as e: logger.error(f"Error loading webhook management pane: {e}", exc_info=True) return HTMLResponse( content=f""" <div class="warning"> <p><strong>Error Loading Webhooks</strong></p> <p>{str(e)}</p> </div> """, status_code=500, ) @requires("authenticated", redirect="oauth_login") async def enable_webhook_preset(request: Request) -> HTMLResponse: """Enable a webhook preset by registering all webhooks. Args: request: Starlette request object (preset_id in path) Returns: HTML response with updated preset card """ preset_id = request.path_params["preset_id"] try: # Get authenticated HTTP client http_client = await _get_authenticated_client(request) username = request.user.display_name # Check admin permissions is_admin = await is_nextcloud_admin(request, http_client) if not is_admin: return HTMLResponse( content='<div class="warning">Admin access required</div>', status_code=403, ) # Get preset configuration preset = get_preset(preset_id) if not preset: return HTMLResponse( content=f'<div class="warning">Unknown preset: {preset_id}</div>', status_code=404, ) # Register webhooks webhooks_client = WebhooksClient(http_client, username) webhook_uri = _get_webhook_uri() registered_ids = [] for event_config in preset["events"]: webhook_data = await webhooks_client.create_webhook( event=event_config["event"], uri=webhook_uri, event_filter=event_config["filter"] if event_config["filter"] else None, ) webhook_id = webhook_data["id"] registered_ids.append(webhook_id) logger.info(f"Registered webhook {webhook_id} for {event_config['event']}") # Persist webhook IDs to database storage = _get_storage(request) if storage: for webhook_id in registered_ids: await storage.store_webhook(webhook_id, preset_id) logger.info( f"Persisted {len(registered_ids)} webhook(s) for preset '{preset_id}' to database" ) # Return updated card num_webhooks = len(registered_ids) return HTMLResponse( content=f""" <div id="preset-{preset_id}" style="border: 1px solid #e0e0e0; border-radius: 6px; padding: 20px; margin: 15px 0;"> <h3 style="margin-top: 0; color: #0082c9;">{preset["name"]}</h3> <p style="color: #666; margin: 10px 0;">{preset["description"]}</p> <p style="font-size: 13px; color: #999;"> <strong>App:</strong> {preset["app"]} | <strong>Events:</strong> {len(preset["events"])} </p> <div style="margin-top: 15px; display: flex; align-items: center; gap: 15px;"> <div><span style="color: #4caf50; font-weight: bold;">✓ Enabled ({num_webhooks} webhooks)</span></div> <div> <button hx-delete="/app/webhooks/disable/{preset_id}" hx-target="#preset-{preset_id}" hx-swap="outerHTML" class="button" style="background-color: #ff9800;"> Disable </button> </div> </div> </div> """ ) except Exception as e: logger.error(f"Failed to enable preset {preset_id}: {e}", exc_info=True) return HTMLResponse( content=f'<div class="warning">Failed to enable preset: {str(e)}</div>', status_code=500, ) @requires("authenticated", redirect="oauth_login") async def disable_webhook_preset(request: Request) -> HTMLResponse: """Disable a webhook preset by deleting all registered webhooks. Args: request: Starlette request object (preset_id in path) Returns: HTML response with updated preset card """ preset_id = request.path_params["preset_id"] try: # Get authenticated HTTP client http_client = await _get_authenticated_client(request) username = request.user.display_name # Check admin permissions is_admin = await is_nextcloud_admin(request, http_client) if not is_admin: return HTMLResponse( content='<div class="warning">Admin access required</div>', status_code=403, ) # Get preset configuration preset = get_preset(preset_id) if not preset: return HTMLResponse( content=f'<div class="warning">Unknown preset: {preset_id}</div>', status_code=404, ) # Find and delete matching webhooks webhooks_client = WebhooksClient(http_client, username) # Get webhook IDs from database first (more reliable) storage = _get_storage(request) if storage: webhook_ids = await storage.get_webhooks_by_preset(preset_id) else: # Fallback to API query if storage not available enabled_presets = await _get_enabled_presets(webhooks_client) webhook_ids = enabled_presets.get(preset_id, []) for webhook_id in webhook_ids: await webhooks_client.delete_webhook(webhook_id) logger.info(f"Deleted webhook {webhook_id} from preset {preset_id}") # Remove from database if storage: deleted_count = await storage.clear_preset_webhooks(preset_id) logger.info( f"Removed {deleted_count} webhook(s) for preset '{preset_id}' from database" ) # Return updated card return HTMLResponse( content=f""" <div id="preset-{preset_id}" style="border: 1px solid #e0e0e0; border-radius: 6px; padding: 20px; margin: 15px 0;"> <h3 style="margin-top: 0; color: #0082c9;">{preset["name"]}</h3> <p style="color: #666; margin: 10px 0;">{preset["description"]}</p> <p style="font-size: 13px; color: #999;"> <strong>App:</strong> {preset["app"]} | <strong>Events:</strong> {len(preset["events"])} </p> <div style="margin-top: 15px; display: flex; align-items: center; gap: 15px;"> <div><span style="color: #999;">Not Enabled</span></div> <div> <button hx-post="/app/webhooks/enable/{preset_id}" hx-target="#preset-{preset_id}" hx-swap="outerHTML" class="button button-primary"> Enable </button> </div> </div> </div> """ ) except Exception as e: logger.error(f"Failed to disable preset {preset_id}: {e}", exc_info=True) return HTMLResponse( content=f'<div class="warning">Failed to disable preset: {str(e)}</div>', status_code=500, )

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cbcoutinho/nextcloud-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server