import click import logging import os import uvicorn from collections.abc import AsyncIterator from contextlib import asynccontextmanager, AsyncExitStack from dataclasses import dataclass from pydantic import AnyHttpUrl from starlette.applications import Starlette from starlette.routing import Mount from mcp.server.fastmcp import Context, FastMCP from mcp.server.auth.settings import AuthSettings from nextcloud_mcp_server.config import setup_logging from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.context import get_client as get_nextcloud_client from nextcloud_mcp_server.auth import ( NextcloudTokenVerifier, load_or_register_client, ) from nextcloud_mcp_server.server import ( configure_calendar_tools, configure_contacts_tools, configure_notes_tools, configure_tables_tools, configure_webdav_tools, configure_deck_tools, ) logger = logging.getLogger(__name__) @dataclass class AppContext: """Application context for BasicAuth mode.""" client: NextcloudClient @dataclass class OAuthAppContext: """Application context for OAuth mode.""" nextcloud_host: str token_verifier: NextcloudTokenVerifier def is_oauth_mode() -> bool: """ Determine if OAuth mode should be used. OAuth mode is enabled when: - NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set - Or explicitly enabled via configuration Returns: True if OAuth mode, False if BasicAuth mode """ username = os.getenv("NEXTCLOUD_USERNAME") password = os.getenv("NEXTCLOUD_PASSWORD") # If both username and password are set, use BasicAuth if username and password: logger.info( "BasicAuth mode detected (NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD set)" ) return False logger.info("OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)") return True @asynccontextmanager async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]: """ Manage application lifecycle for BasicAuth mode. Creates a single Nextcloud client with basic authentication that is shared across all requests. """ logger.info("Starting MCP server in BasicAuth mode") logger.info("Creating Nextcloud client with BasicAuth") client = NextcloudClient.from_env() logger.info("Client initialization complete") try: yield AppContext(client=client) finally: logger.info("Shutting down BasicAuth mode") await client.close() @asynccontextmanager async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]: """ Manage application lifecycle for OAuth mode. Initializes OAuth client registration and token verifier. Does NOT create a Nextcloud client - clients are created per-request. """ logger.info("Starting MCP server in OAuth mode") nextcloud_host = os.getenv("NEXTCLOUD_HOST") if not nextcloud_host: raise ValueError("NEXTCLOUD_HOST environment variable is required") nextcloud_host = nextcloud_host.rstrip("/") # Get OAuth discovery endpoint discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" try: # Fetch OIDC discovery import httpx async with httpx.AsyncClient() as client: response = await client.get(discovery_url) response.raise_for_status() discovery = response.json() logger.info(f"OIDC discovery successful: {discovery_url}") # Extract endpoints userinfo_uri = discovery["userinfo_endpoint"] registration_endpoint = discovery.get("registration_endpoint") logger.info(f"Userinfo endpoint: {userinfo_uri}") # Handle client registration client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET") storage_path = os.getenv( "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" ) if client_id and client_secret: logger.info("Using pre-configured OAuth client credentials") elif registration_endpoint: logger.info("Dynamic client registration available") mcp_server_url = os.getenv( "NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000" ) redirect_uris = [f"{mcp_server_url}/oauth/callback"] # Load or register client client_info = await load_or_register_client( nextcloud_url=nextcloud_host, registration_endpoint=registration_endpoint, storage_path=storage_path, client_name="Nextcloud MCP Server", redirect_uris=redirect_uris, ) logger.info(f"OAuth client ready: {client_info.client_id[:16]}...") else: raise ValueError( "OAuth mode requires either:\n" "1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n" "2. Dynamic client registration enabled on Nextcloud OIDC app" ) # Create token verifier token_verifier = NextcloudTokenVerifier( nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri ) logger.info("OAuth initialization complete") try: yield OAuthAppContext( nextcloud_host=nextcloud_host, token_verifier=token_verifier ) finally: logger.info("Shutting down OAuth mode") await token_verifier.close() except Exception as e: logger.error(f"Failed to initialize OAuth mode: {e}") raise async def setup_oauth_config(): """ Setup OAuth configuration by performing OIDC discovery and client registration. This is done synchronously before FastMCP initialization because FastMCP requires token_verifier at construction time. Returns: Tuple of (nextcloud_host, token_verifier, auth_settings) """ nextcloud_host = os.getenv("NEXTCLOUD_HOST") if not nextcloud_host: raise ValueError( "NEXTCLOUD_HOST environment variable is required for OAuth mode" ) nextcloud_host = nextcloud_host.rstrip("/") discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" logger.info(f"Performing OIDC discovery: {discovery_url}") # Fetch OIDC discovery import httpx async with httpx.AsyncClient() as client: response = await client.get(discovery_url) response.raise_for_status() discovery = response.json() logger.info("OIDC discovery successful") # Extract endpoints issuer = discovery["issuer"] userinfo_uri = discovery["userinfo_endpoint"] registration_endpoint = discovery.get("registration_endpoint") # Handle client registration client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET") if client_id and client_secret: logger.info("Using pre-configured OAuth client credentials") elif registration_endpoint: logger.info("Dynamic client registration available") storage_path = os.getenv( "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" ) mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") redirect_uris = [f"{mcp_server_url}/oauth/callback"] # Load or register client client_info = await load_or_register_client( nextcloud_url=nextcloud_host, registration_endpoint=registration_endpoint, storage_path=storage_path, client_name="Nextcloud MCP Server", redirect_uris=redirect_uris, ) logger.info(f"OAuth client ready: {client_info.client_id[:16]}...") else: raise ValueError( "OAuth mode requires either:\n" "1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n" "2. Dynamic client registration enabled on Nextcloud OIDC app" ) # Create token verifier token_verifier = NextcloudTokenVerifier( nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri ) # Create auth settings mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") auth_settings = AuthSettings( issuer_url=AnyHttpUrl(issuer), resource_server_url=AnyHttpUrl(mcp_server_url), required_scopes=["openid", "profile"], ) logger.info("OAuth configuration complete") return nextcloud_host, token_verifier, auth_settings def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): setup_logging() # Determine authentication mode oauth_enabled = is_oauth_mode() if oauth_enabled: logger.info("Configuring MCP server for OAuth mode") # Asynchronously get the OAuth configuration import asyncio nextcloud_host, token_verifier, auth_settings = asyncio.run( setup_oauth_config() ) mcp = FastMCP( "Nextcloud MCP", lifespan=app_lifespan_oauth, token_verifier=token_verifier, auth=auth_settings, ) else: logger.info("Configuring MCP server for BasicAuth mode") mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic) @mcp.resource("nc://capabilities") async def nc_get_capabilities(): """Get the Nextcloud Host capabilities""" ctx: Context = mcp.get_context() client = get_nextcloud_client(ctx) return await client.capabilities() # Define available apps and their configuration functions available_apps = { "notes": configure_notes_tools, "tables": configure_tables_tools, "webdav": configure_webdav_tools, "calendar": configure_calendar_tools, "contacts": configure_contacts_tools, "deck": configure_deck_tools, } # If no specific apps are specified, enable all if enabled_apps is None: enabled_apps = list(available_apps.keys()) # Configure only the enabled apps for app_name in enabled_apps: if app_name in available_apps: logger.info(f"Configuring {app_name} tools") available_apps[app_name](mcp) else: logger.warning( f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}" ) if transport == "sse": mcp_app = mcp.sse_app() lifespan = None elif transport in ("http", "streamable-http"): mcp_app = mcp.streamable_http_app() @asynccontextmanager async def lifespan(app: Starlette): async with AsyncExitStack() as stack: await stack.enter_async_context(mcp.session_manager.run()) yield app = Starlette(routes=[Mount("/", app=mcp_app)], lifespan=lifespan) 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( "--workers", "-w", type=int, default=None, help="Number of worker processes" ) @click.option("--reload", "-r", is_flag=True, help="Enable auto-reload") @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", "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( "--oauth-storage-path", envvar="NEXTCLOUD_OIDC_CLIENT_STORAGE", default=".nextcloud_oauth_client.json", show_default=True, help="Path to store OAuth client credentials (can also use NEXTCLOUD_OIDC_CLIENT_STORAGE 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)", ) def run( host: str, port: int, workers: int, reload: bool, log_level: str, transport: str, enable_app: tuple[str, ...], oauth: bool | None, oauth_client_id: str | None, oauth_client_secret: str | None, oauth_storage_path: str, mcp_server_url: str, ): """ 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 (legacy) $ nextcloud-mcp-server --host 0.0.0.0 --port 8000 # OAuth mode with auto-registration $ nextcloud-mcp-server --oauth # OAuth mode with pre-configured client $ nextcloud-mcp-server --oauth --oauth-client-id=xxx --oauth-client-secret=yyy """ # Set OAuth env vars from CLI options if provided 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_storage_path: os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path if mcp_server_url: os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_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: " + os.getenv( "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" ), 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 if reload or workers: app = "nextcloud_mcp_server.app:get_app" factory = True else: app = get_app(transport=transport, enabled_apps=enabled_apps) factory = False uvicorn.run( app=app, factory=factory, host=host, port=port, reload=reload, workers=workers, log_level=log_level, ) if __name__ == "__main__": run()