diff --git a/app-hooks/post-installation/25-configure-mcp-server-url.sh b/app-hooks/post-installation/25-configure-mcp-server-url.sh new file mode 100755 index 0000000..daa9df0 --- /dev/null +++ b/app-hooks/post-installation/25-configure-mcp-server-url.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Configure MCP server URL for Astrolabe background sync +# This URL is used by Astrolabe to send app passwords to the MCP server + +set -e + +# The MCP multi-user BasicAuth service runs on port 8000 inside the container +# From Nextcloud's perspective (inside Docker network), we reach it via service name +MCP_SERVER_URL="${MCP_SERVER_URL:-http://mcp-multi-user-basic:8000}" + +echo "Configuring MCP server URL: $MCP_SERVER_URL" + +# Set the mcp_server_url in config.php via occ +php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL" + +echo "MCP server URL configured successfully" diff --git a/nextcloud_mcp_server/alembic/versions/20260113_1200_002_add_app_passwords.py b/nextcloud_mcp_server/alembic/versions/20260113_1200_002_add_app_passwords.py new file mode 100644 index 0000000..9e7b36c --- /dev/null +++ b/nextcloud_mcp_server/alembic/versions/20260113_1200_002_add_app_passwords.py @@ -0,0 +1,50 @@ +"""Add app_passwords table for multi-user BasicAuth mode + +This migration adds support for storing app passwords that are provisioned +via Astrolabe's personal settings. This enables background sync in +multi-user BasicAuth mode without requiring OAuth. + +Revision ID: 002 +Revises: 001 +Create Date: 2026-01-13 12:00:00.000000 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "002" +down_revision = "001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add app_passwords table for multi-user BasicAuth mode.""" + + # App passwords table for multi-user BasicAuth background sync + op.execute( + """ + CREATE TABLE IF NOT EXISTS app_passwords ( + user_id TEXT PRIMARY KEY, + encrypted_password BLOB NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + + # Index for efficient user lookups + op.execute( + """ + CREATE INDEX IF NOT EXISTS idx_app_passwords_updated + ON app_passwords(updated_at) + """ + ) + + +def downgrade() -> None: + """Drop app_passwords table.""" + + op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated") + op.execute("DROP TABLE IF EXISTS app_passwords") diff --git a/nextcloud_mcp_server/api/management.py b/nextcloud_mcp_server/api/management.py index 6b57259..512bd85 100644 --- a/nextcloud_mcp_server/api/management.py +++ b/nextcloud_mcp_server/api/management.py @@ -510,6 +510,342 @@ async def revoke_user_access(request: Request) -> JSONResponse: ) +async def provision_app_password(request: Request) -> JSONResponse: + """POST /api/v1/users/{user_id}/app-password - Store app password for background sync. + + This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords + for multi-user BasicAuth mode background sync. + + The request must include BasicAuth credentials where: + - username: Nextcloud user ID (must match path user_id) + - password: The app password being provisioned + + The MCP server validates the app password against Nextcloud before storing it. + This proves the user owns the password and has access to Nextcloud. + + Security model: + - User identity is verified via BasicAuth against Nextcloud + - App password is encrypted before storage + - Only the user who owns the password can provision it + """ + import base64 + + # Get user_id from path + path_user_id = request.path_params.get("user_id") + if not path_user_id: + return JSONResponse( + {"success": False, "error": "Missing user_id in path"}, + status_code=400, + ) + + # Extract BasicAuth credentials + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Basic "): + return JSONResponse( + {"success": False, "error": "Missing BasicAuth credentials"}, + status_code=401, + ) + + try: + # Decode BasicAuth + encoded = auth_header.split(" ", 1)[1] + decoded = base64.b64decode(encoded).decode("utf-8") + username, app_password = decoded.split(":", 1) + except Exception: + return JSONResponse( + {"success": False, "error": "Invalid BasicAuth format"}, + status_code=401, + ) + + # Verify username matches path user_id + if username != path_user_id: + logger.warning( + f"Username mismatch in app password provisioning: " + f"path={path_user_id}, auth={username}" + ) + return JSONResponse( + {"success": False, "error": "Username does not match path user_id"}, + status_code=403, + ) + + # Validate app password format (xxxxx-xxxxx-xxxxx-xxxxx-xxxxx) + import re + + if not re.match( + r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$", + app_password, + ): + return JSONResponse( + {"success": False, "error": "Invalid app password format"}, + status_code=400, + ) + + # Get Nextcloud host from settings + from nextcloud_mcp_server.config import get_settings + + settings = get_settings() + nextcloud_host = settings.nextcloud_host + + if not nextcloud_host: + logger.error("NEXTCLOUD_HOST not configured") + return JSONResponse( + {"success": False, "error": "Server not configured"}, + status_code=500, + ) + + # Validate app password against Nextcloud + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Use OCS API to verify credentials + test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user" + response = await client.get( + test_url, + auth=(username, app_password), + params={"format": "json"}, + headers={"OCS-APIRequest": "true"}, + ) + + if response.status_code != 200: + logger.warning( + f"App password validation failed for {username}: " + f"HTTP {response.status_code}" + ) + return JSONResponse( + {"success": False, "error": "Invalid app password"}, + status_code=401, + ) + + # Verify the user ID from response matches + data = response.json() + ocs_user_id = data.get("ocs", {}).get("data", {}).get("id") + if ocs_user_id != username: + logger.warning( + f"User ID mismatch: expected {username}, got {ocs_user_id}" + ) + return JSONResponse( + {"success": False, "error": "User ID mismatch"}, + status_code=403, + ) + + except httpx.RequestError as e: + logger.error(f"Failed to validate app password: {e}") + return JSONResponse( + {"success": False, "error": "Failed to validate credentials"}, + status_code=500, + ) + + # Store the validated app password + try: + # Get storage from app state or create from env + storage = getattr(request.app.state, "storage", None) + + if not storage: + # Multi-user BasicAuth mode may not have oauth_context + # Initialize storage from environment + from nextcloud_mcp_server.auth.storage import RefreshTokenStorage + + storage = RefreshTokenStorage.from_env() + await storage.initialize() + + await storage.store_app_password(username, app_password) + + logger.info(f"Provisioned app password for user: {username}") + + return JSONResponse( + { + "success": True, + "message": f"App password stored for {username}", + } + ) + + except Exception as e: + error_msg = _sanitize_error_for_client(e, "provision_app_password") + return JSONResponse( + {"success": False, "error": error_msg}, + status_code=500, + ) + + +async def get_app_password_status(request: Request) -> JSONResponse: + """GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password. + + Returns status of background sync access for multi-user BasicAuth mode. + + Requires BasicAuth with the user's app password for authentication. + """ + import base64 + + # Get user_id from path + path_user_id = request.path_params.get("user_id") + if not path_user_id: + return JSONResponse( + {"success": False, "error": "Missing user_id in path"}, + status_code=400, + ) + + # Extract BasicAuth credentials + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Basic "): + return JSONResponse( + {"success": False, "error": "Missing BasicAuth credentials"}, + status_code=401, + ) + + try: + # Decode BasicAuth + encoded = auth_header.split(" ", 1)[1] + decoded = base64.b64decode(encoded).decode("utf-8") + username, _ = decoded.split(":", 1) + except Exception: + return JSONResponse( + {"success": False, "error": "Invalid BasicAuth format"}, + status_code=401, + ) + + # Verify username matches path user_id + if username != path_user_id: + return JSONResponse( + {"success": False, "error": "Username does not match path user_id"}, + status_code=403, + ) + + try: + # Get storage + storage = getattr(request.app.state, "storage", None) + + if not storage: + from nextcloud_mcp_server.auth.storage import RefreshTokenStorage + + storage = RefreshTokenStorage.from_env() + await storage.initialize() + + # Check if app password exists + app_password = await storage.get_app_password(username) + + return JSONResponse( + { + "success": True, + "user_id": username, + "has_app_password": app_password is not None, + } + ) + + except Exception as e: + error_msg = _sanitize_error_for_client(e, "get_app_password_status") + return JSONResponse( + {"success": False, "error": error_msg}, + status_code=500, + ) + + +async def delete_app_password(request: Request) -> JSONResponse: + """DELETE /api/v1/users/{user_id}/app-password - Delete stored app password. + + Removes the user's app password from MCP server storage. + + Requires BasicAuth with the user's credentials. + """ + import base64 + + from nextcloud_mcp_server.config import get_settings + + # Get user_id from path + path_user_id = request.path_params.get("user_id") + if not path_user_id: + return JSONResponse( + {"success": False, "error": "Missing user_id in path"}, + status_code=400, + ) + + # Extract BasicAuth credentials + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Basic "): + return JSONResponse( + {"success": False, "error": "Missing BasicAuth credentials"}, + status_code=401, + ) + + try: + # Decode BasicAuth + encoded = auth_header.split(" ", 1)[1] + decoded = base64.b64decode(encoded).decode("utf-8") + username, password = decoded.split(":", 1) + except Exception: + return JSONResponse( + {"success": False, "error": "Invalid BasicAuth format"}, + status_code=401, + ) + + # Verify username matches path user_id + if username != path_user_id: + return JSONResponse( + {"success": False, "error": "Username does not match path user_id"}, + status_code=403, + ) + + # Validate credentials against Nextcloud + settings = get_settings() + nextcloud_host = settings.nextcloud_host + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user" + response = await client.get( + test_url, + auth=(username, password), + params={"format": "json"}, + headers={"OCS-APIRequest": "true"}, + ) + + if response.status_code != 200: + return JSONResponse( + {"success": False, "error": "Invalid credentials"}, + status_code=401, + ) + except httpx.RequestError as e: + logger.error(f"Failed to validate credentials: {e}") + return JSONResponse( + {"success": False, "error": "Failed to validate credentials"}, + status_code=500, + ) + + try: + # Get storage + storage = getattr(request.app.state, "storage", None) + + if not storage: + from nextcloud_mcp_server.auth.storage import RefreshTokenStorage + + storage = RefreshTokenStorage.from_env() + await storage.initialize() + + # Delete app password + deleted = await storage.delete_app_password(username) + + if deleted: + logger.info(f"Deleted app password for user: {username}") + return JSONResponse( + { + "success": True, + "message": f"App password deleted for {username}", + } + ) + else: + return JSONResponse( + { + "success": True, + "message": "No app password found to delete", + } + ) + + except Exception as e: + error_msg = _sanitize_error_for_client(e, "delete_app_password") + return JSONResponse( + {"success": False, "error": error_msg}, + status_code=500, + ) + + async def get_installed_apps(request: Request) -> JSONResponse: """GET /api/v1/apps - Get list of installed Nextcloud apps. diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 4accbb1..2579408 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -2012,7 +2012,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = checks["auth_mode"] = "multi_user_basic" checks["auth_configured"] = "ok" # Indicate if app passwords are supported (when offline_access enabled) - checks["supports_app_passwords"] = settings.enable_offline_access + checks["supports_app_passwords"] = get_settings().enable_offline_access elif mode == AuthMode.SINGLE_USER_BASIC: username = os.getenv("NEXTCLOUD_USERNAME") password = os.getenv("NEXTCLOUD_PASSWORD") @@ -2029,9 +2029,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = # Check Qdrant status if using network mode (external Qdrant service) # In-memory and persistent modes use embedded Qdrant, no external service to check - vector_sync_enabled = ( - os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true" - ) + # Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED + settings = get_settings() + vector_sync_enabled = settings.vector_sync_enabled qdrant_url = os.getenv("QDRANT_URL") # Only set in network mode if vector_sync_enabled and qdrant_url: @@ -2114,13 +2114,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = if enable_management_apis: from nextcloud_mcp_server.api.management import ( create_webhook, + delete_app_password, delete_webhook, + get_app_password_status, get_chunk_context, get_installed_apps, get_server_status, get_user_session, get_vector_sync_status, list_webhooks, + provision_app_password, revoke_user_access, unified_search, vector_search, @@ -2148,6 +2151,28 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = methods=["POST"], ) ) + # App password endpoints for multi-user BasicAuth mode + routes.append( + Route( + "/api/v1/users/{user_id}/app-password", + provision_app_password, + methods=["POST"], + ) + ) + routes.append( + Route( + "/api/v1/users/{user_id}/app-password", + get_app_password_status, + methods=["GET"], + ) + ) + routes.append( + Route( + "/api/v1/users/{user_id}/app-password", + delete_app_password, + methods=["DELETE"], + ) + ) routes.append( Route("/api/v1/vector-viz/search", vector_search, methods=["POST"]) ) @@ -2166,6 +2191,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = logger.info( "Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, " "/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, " + "/api/v1/users/{user_id}/app-password, " "/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, " "/api/v1/webhooks" ) diff --git a/nextcloud_mcp_server/auth/storage.py b/nextcloud_mcp_server/auth/storage.py index 19852a8..4424ce8 100644 --- a/nextcloud_mcp_server/auth/storage.py +++ b/nextcloud_mcp_server/auth/storage.py @@ -1240,6 +1240,180 @@ class RefreshTokenStorage: return deleted + # ============================================================================ + # App Password Storage (multi-user BasicAuth mode) + # ============================================================================ + + async def store_app_password( + self, + user_id: str, + app_password: str, + ) -> None: + """ + Store encrypted app password for background sync (multi-user BasicAuth mode). + + Args: + user_id: Nextcloud user ID + app_password: Nextcloud app password to store + """ + if not self._initialized: + await self.initialize() + + if not self.cipher: + raise RuntimeError( + "Encryption key not configured. " + "Set TOKEN_ENCRYPTION_KEY for app password storage." + ) + + encrypted_password = self.cipher.encrypt(app_password.encode()) + now = int(time.time()) + + start_time = time.time() + try: + async with aiosqlite.connect(self.db_path) as db: + await db.execute( + """ + INSERT OR REPLACE INTO app_passwords + (user_id, encrypted_password, created_at, updated_at) + VALUES ( + ?, + ?, + COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?), + ? + ) + """, + (user_id, encrypted_password, user_id, now, now), + ) + await db.commit() + + duration = time.time() - start_time + record_db_operation("sqlite", "insert", duration, "success") + logger.info(f"Stored app password for user {user_id}") + + except Exception: + duration = time.time() - start_time + record_db_operation("sqlite", "insert", duration, "error") + raise + + # Audit log + await self._audit_log( + event="store_app_password", + user_id=user_id, + auth_method="app_password", + ) + + async def get_app_password(self, user_id: str) -> Optional[str]: + """ + Retrieve and decrypt app password for a user. + + Args: + user_id: Nextcloud user ID + + Returns: + Decrypted app password, or None if not found + """ + if not self._initialized: + await self.initialize() + + if not self.cipher: + raise RuntimeError( + "Encryption key not configured. " + "Set TOKEN_ENCRYPTION_KEY for app password retrieval." + ) + + start_time = time.time() + try: + async with aiosqlite.connect(self.db_path) as db: + async with db.execute( + "SELECT encrypted_password FROM app_passwords WHERE user_id = ?", + (user_id,), + ) as cursor: + row = await cursor.fetchone() + + if not row: + logger.debug(f"No app password found for user {user_id}") + duration = time.time() - start_time + record_db_operation("sqlite", "select", duration, "success") + return None + + encrypted_password = row[0] + decrypted_password = self.cipher.decrypt(encrypted_password).decode() + + duration = time.time() - start_time + record_db_operation("sqlite", "select", duration, "success") + logger.debug(f"Retrieved app password for user {user_id}") + + return decrypted_password + + except Exception as e: + duration = time.time() - start_time + record_db_operation("sqlite", "select", duration, "error") + logger.error(f"Failed to decrypt app password for user {user_id}: {e}") + return None + + async def delete_app_password(self, user_id: str) -> bool: + """ + Delete app password for a user. + + Args: + user_id: Nextcloud user ID + + Returns: + True if password was deleted, False if not found + """ + if not self._initialized: + await self.initialize() + + start_time = time.time() + try: + async with aiosqlite.connect(self.db_path) as db: + cursor = await db.execute( + "DELETE FROM app_passwords WHERE user_id = ?", + (user_id,), + ) + await db.commit() + deleted = cursor.rowcount > 0 + + duration = time.time() - start_time + record_db_operation("sqlite", "delete", duration, "success") + + if deleted: + logger.info(f"Deleted app password for user {user_id}") + await self._audit_log( + event="delete_app_password", + user_id=user_id, + auth_method="app_password", + ) + else: + logger.debug(f"No app password to delete for user {user_id}") + + return deleted + + except Exception: + duration = time.time() - start_time + record_db_operation("sqlite", "delete", duration, "error") + raise + + async def get_all_app_password_user_ids(self) -> list[str]: + """ + Get list of all user IDs with stored app passwords. + + Returns: + List of user IDs + """ + if not self._initialized: + await self.initialize() + + async with aiosqlite.connect(self.db_path) as db: + async with db.execute( + "SELECT user_id FROM app_passwords ORDER BY updated_at DESC" + ) as cursor: + rows = await cursor.fetchall() + + user_ids = [row[0] for row in rows] + logger.debug(f"Found {len(user_ids)} users with app passwords") + return user_ids + async def generate_encryption_key() -> str: """ diff --git a/nextcloud_mcp_server/vector/oauth_sync.py b/nextcloud_mcp_server/vector/oauth_sync.py index 9e1810a..c85e4ee 100644 --- a/nextcloud_mcp_server/vector/oauth_sync.py +++ b/nextcloud_mcp_server/vector/oauth_sync.py @@ -8,8 +8,8 @@ Manages background vector sync for multi-user deployments: Authentication strategies are mutually exclusive by deployment mode: Multi-user BasicAuth mode (ENABLE_MULTI_USER_BASIC_AUTH=true): -- Uses app passwords obtained via Astrolabe Management API -- Users provision via Astrolabe personal settings +- Uses app passwords stored locally in MCP server's database +- Users provision via Astrolabe personal settings, which sends to MCP API - OAuth is NOT used OAuth mode (with external IdP like Keycloak): @@ -33,7 +33,6 @@ from anyio.streams.memory import ( ) from httpx import BasicAuth -from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents @@ -71,15 +70,18 @@ class UserSyncState: async def get_user_client_basic_auth( user_id: str, nextcloud_host: str, + storage: "RefreshTokenStorage | None" = None, ) -> NextcloudClient: """Get an authenticated NextcloudClient using app password (BasicAuth mode). For multi-user BasicAuth deployments where users provision app passwords - via Astrolabe personal settings. OAuth is NOT used in this mode. + via Astrolabe personal settings. The app password is stored locally in the + MCP server's database after being provisioned through the management API. Args: user_id: User identifier nextcloud_host: Nextcloud base URL + storage: Optional RefreshTokenStorage instance (created from env if not provided) Returns: Authenticated NextcloudClient with BasicAuth @@ -87,21 +89,15 @@ async def get_user_client_basic_auth( Raises: NotProvisionedError: If user has not provisioned an app password """ - settings = get_settings() + from nextcloud_mcp_server.auth.storage import RefreshTokenStorage - if not settings.oidc_client_id or not settings.oidc_client_secret: - raise NotProvisionedError( - "Astrolabe client credentials not configured. " - "Set OIDC_CLIENT_ID and OIDC_CLIENT_SECRET for app password retrieval." - ) + # Get or create storage instance + if storage is None: + storage = RefreshTokenStorage.from_env() + await storage.initialize() - astrolabe = AstrolabeClient( - nextcloud_host=nextcloud_host, - client_id=settings.oidc_client_id, - client_secret=settings.oidc_client_secret, - ) - - app_password = await astrolabe.get_user_app_password(user_id) + # Retrieve app password from local storage + app_password = await storage.get_app_password(user_id) if not app_password: raise NotProvisionedError( @@ -419,8 +415,15 @@ async def user_manager_task( while not shutdown_event.is_set(): try: - # Get current provisioned users - provisioned_users = set(await refresh_token_storage.get_all_user_ids()) + # Get current provisioned users based on mode + if use_basic_auth: + # BasicAuth mode: query app_passwords table + provisioned_users = set( + await refresh_token_storage.get_all_app_password_user_ids() + ) + else: + # OAuth mode: query refresh_tokens table + provisioned_users = set(await refresh_token_storage.get_all_user_ids()) active_users = set(user_states.keys()) # Start scanners for new users diff --git a/third_party/astrolabe/lib/Controller/CredentialsController.php b/third_party/astrolabe/lib/Controller/CredentialsController.php index c081886..c21cff7 100644 --- a/third_party/astrolabe/lib/Controller/CredentialsController.php +++ b/third_party/astrolabe/lib/Controller/CredentialsController.php @@ -94,24 +94,78 @@ class CredentialsController extends Controller { ], Http::STATUS_UNAUTHORIZED); } - // Store encrypted app password + // Store encrypted app password locally in Nextcloud try { $this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword); - $this->logger->info("Successfully stored app password for user: $userId"); - - return new JSONResponse([ - 'success' => true, - 'message' => 'App password saved successfully' - ], Http::STATUS_OK); + $this->logger->info("Stored app password locally for user: $userId"); } catch (\Exception $e) { - $this->logger->error("Failed to store app password for user $userId", [ + $this->logger->error("Failed to store app password locally for user $userId", [ 'error' => $e->getMessage() ]); return new JSONResponse([ 'success' => false, - 'error' => 'Failed to save app password' + 'error' => 'Failed to save app password locally' ], Http::STATUS_INTERNAL_SERVER_ERROR); } + + // Send app password to MCP server for background sync + // Get MCP server URL from system config (set in config.php) + $mcpServerUrl = $this->config->getSystemValue('mcp_server_url', ''); + if (empty($mcpServerUrl)) { + $this->logger->warning("MCP server URL not configured, app password stored locally only"); + return new JSONResponse([ + 'success' => true, + 'message' => 'App password saved locally (MCP server not configured)' + ], Http::STATUS_OK); + } + + try { + $httpClient = $this->httpClientService->newClient(); + + // Send to MCP server with BasicAuth (user proves ownership of password) + $mcpEndpoint = rtrim($mcpServerUrl, '/') . '/api/v1/users/' . urlencode($userId) . '/app-password'; + + $this->logger->debug("Sending app password to MCP server: $mcpEndpoint"); + + $response = $httpClient->post($mcpEndpoint, [ + 'auth' => [$userId, $appPassword], + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'timeout' => 10, + ]); + + $statusCode = $response->getStatusCode(); + $body = json_decode($response->getBody(), true); + + if ($statusCode === 200 && ($body['success'] ?? false)) { + $this->logger->info("Successfully provisioned app password to MCP server for user: $userId"); + return new JSONResponse([ + 'success' => true, + 'message' => 'App password saved successfully' + ], Http::STATUS_OK); + } else { + $error = $body['error'] ?? 'Unknown error'; + $this->logger->error("MCP server rejected app password for user $userId: $error"); + // Still return success since it was stored locally + return new JSONResponse([ + 'success' => true, + 'message' => 'App password saved locally (MCP server sync failed)', + 'warning' => $error + ], Http::STATUS_OK); + } + } catch (\Exception $e) { + $this->logger->error("Failed to send app password to MCP server for user $userId", [ + 'error' => $e->getMessage() + ]); + // Still return success since it was stored locally + return new JSONResponse([ + 'success' => true, + 'message' => 'App password saved locally (MCP server unreachable)', + 'warning' => $e->getMessage() + ], Http::STATUS_OK); + } } /**