diff --git a/Dockerfile.smithery b/Dockerfile.smithery new file mode 100644 index 0000000..b80bde5 --- /dev/null +++ b/Dockerfile.smithery @@ -0,0 +1,45 @@ +# Dockerfile for Smithery stateless deployment +# ADR-016: Stateless mode for multi-user public Nextcloud instances +# +# This image excludes: +# - Vector database dependencies (qdrant-client) +# - Background sync workers +# - Admin UI routes (/app) +# - Semantic search tools +# +# Features included: +# - Core Nextcloud tools (notes, calendar, contacts, files, deck, tables, cookbook) +# - Per-session app password authentication +# - Multi-user support via Smithery session config + +FROM docker.io/library/python:3.12-slim-trixie@sha256:2e683fc3e18a248aa23b8022f2a3474b072b04fb851efe9b49f6b516a8944939 + +WORKDIR /app + +# Install uv for fast dependency management +COPY --from=ghcr.io/astral-sh/uv:0.9.10@sha256:29bd45092ea8902c0bbb7f0a338f0494a382b1f4b18355df5be270ade679ff1d /uv /uvx /bin/ + +# Copy dependency files first (for better layer caching) +COPY pyproject.toml uv.lock ./ + +# Copy application code +COPY nextcloud_mcp_server ./nextcloud_mcp_server + +# Install dependencies without vector/semantic extras +# Use --no-dev to exclude development dependencies +RUN uv sync --frozen --no-dev --no-install-project && \ + uv pip install -e . --no-deps + +# Set Smithery mode environment variables +ENV SMITHERY_DEPLOYMENT=true +ENV VECTOR_SYNC_ENABLED=false + +# Smithery sets PORT=8081 by default +EXPOSE 8081 + +# Health check endpoint +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD uv run python -c "import httpx; httpx.get('http://localhost:${PORT:-8081}/health/live').raise_for_status()" + +# Run the Smithery-specific entrypoint +CMD ["uv", "run", "python", "-m", "nextcloud_mcp_server.smithery_main"] diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index f532bd4..b31a595 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -36,6 +36,8 @@ from nextcloud_mcp_server.auth import ( from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.config import ( + DeploymentMode, + get_deployment_mode, get_document_processor_config, get_settings, ) @@ -957,8 +959,12 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): ) # Register semantic search tools (cross-app feature) + # ADR-016: Skip in Smithery stateless mode (no vector database) settings = get_settings() - if settings.vector_sync_enabled: + deployment_mode = get_deployment_mode() + if deployment_mode == DeploymentMode.SMITHERY_STATELESS: + logger.info("Skipping semantic search tools (Smithery stateless mode)") + elif settings.vector_sync_enabled: logger.info("Configuring semantic search tools (vector sync enabled)") configure_semantic_tools(mcp) else: @@ -1491,85 +1497,98 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): ) # Add user info routes (available in both BasicAuth and OAuth modes) - # These require session authentication, so we wrap them in a separate app - from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend - from nextcloud_mcp_server.auth.userinfo_routes import ( - revoke_session, - user_info_html, - vector_sync_status_fragment, - ) - from nextcloud_mcp_server.auth.viz_routes import ( - chunk_context_endpoint, - vector_visualization_html, - vector_visualization_search, - ) - 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_html, methods=["GET"]), # /app → user info with all tabs - Route( - "/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint" - ), # /app/revoke → revoke_session - # Vector sync status fragment (htmx polling) - Route( - "/vector-sync/status", + # ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks) + if deployment_mode != DeploymentMode.SMITHERY_STATELESS: + # These require session authentication, so we wrap them in a separate app + from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend + from nextcloud_mcp_server.auth.userinfo_routes import ( + revoke_session, + user_info_html, vector_sync_status_fragment, - methods=["GET"], - ), # /app/vector-sync/status - # Vector visualization routes - Route( - "/vector-viz", vector_visualization_html, methods=["GET"] - ), # /app/vector-viz - Route( - "/vector-viz/search", - vector_visualization_search, - methods=["GET"], - ), # /app/vector-viz/search - Route( - "/chunk-context", - chunk_context_endpoint, - methods=["GET"], - ), # /app/chunk-context - # 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"], - ), - ] - - # Add static files mount if directory exists - static_dir = os.path.join(os.path.dirname(__file__), "auth", "static") - if os.path.isdir(static_dir): - browser_routes.append( - Mount("/static", StaticFiles(directory=static_dir), name="static") ) - logger.info(f"Mounted static files from {static_dir}") + from nextcloud_mcp_server.auth.viz_routes import ( + chunk_context_endpoint, + vector_visualization_html, + vector_visualization_search, + ) + from nextcloud_mcp_server.auth.webhook_routes import ( + disable_webhook_preset, + enable_webhook_preset, + webhook_management_pane, + ) - browser_app = Starlette(routes=browser_routes) - browser_app.add_middleware( - AuthenticationMiddleware, # type: ignore[invalid-argument-type] - backend=SessionAuthBackend(oauth_enabled=oauth_enabled), - ) + # 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_html, methods=["GET"] + ), # /app → user info with all tabs + Route( + "/revoke", + revoke_session, + methods=["POST"], + name="revoke_session_endpoint", + ), # /app/revoke → revoke_session + # Vector sync status fragment (htmx polling) + Route( + "/vector-sync/status", + vector_sync_status_fragment, + methods=["GET"], + ), # /app/vector-sync/status + # Vector visualization routes + Route( + "/vector-viz", vector_visualization_html, methods=["GET"] + ), # /app/vector-viz + Route( + "/vector-viz/search", + vector_visualization_search, + methods=["GET"], + ), # /app/vector-viz/search + Route( + "/chunk-context", + chunk_context_endpoint, + methods=["GET"], + ), # /app/chunk-context + # 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"], + ), + ] - # Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps) - routes.append( - Route("/app", lambda request: RedirectResponse("/app/", status_code=307)) - ) + # Add static files mount if directory exists + static_dir = os.path.join(os.path.dirname(__file__), "auth", "static") + if os.path.isdir(static_dir): + browser_routes.append( + Mount("/static", StaticFiles(directory=static_dir), name="static") + ) + logger.info(f"Mounted static files from {static_dir}") - # 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") + browser_app = Starlette(routes=browser_routes) + browser_app.add_middleware( + AuthenticationMiddleware, # type: ignore[invalid-argument-type] + backend=SessionAuthBackend(oauth_enabled=oauth_enabled), + ) + + # 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") + else: + logger.info("Admin UI (/app) disabled in Smithery stateless mode") # Mount FastMCP at root last (catch-all, handles OAuth via token_verifier) routes.append(Mount("/", app=mcp_app)) diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index d64e6a2..f59ea4c 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -2,8 +2,37 @@ import logging import logging.config import os from dataclasses import dataclass +from enum import Enum from typing import Any, Optional + +class DeploymentMode(Enum): + """Deployment mode for the MCP server. + + SELF_HOSTED: Full features, environment-based configuration. + Supports vector sync, semantic search, admin UI. + + SMITHERY_STATELESS: Stateless mode for Smithery hosting. + Session-based configuration, no persistent storage. + Excludes semantic search, vector sync, admin UI. + """ + + SELF_HOSTED = "self_hosted" + SMITHERY_STATELESS = "smithery" + + +def get_deployment_mode() -> DeploymentMode: + """Detect deployment mode from environment. + + Returns: + DeploymentMode.SMITHERY_STATELESS if SMITHERY_DEPLOYMENT=true, + otherwise DeploymentMode.SELF_HOSTED (default). + """ + if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true": + return DeploymentMode.SMITHERY_STATELESS + return DeploymentMode.SELF_HOSTED + + LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, diff --git a/nextcloud_mcp_server/context.py b/nextcloud_mcp_server/context.py index 505274b..5ab47d1 100644 --- a/nextcloud_mcp_server/context.py +++ b/nextcloud_mcp_server/context.py @@ -1,21 +1,37 @@ """Helper functions for accessing context in MCP tools.""" +import logging + +from httpx import BasicAuth from mcp.server.fastmcp import Context from nextcloud_mcp_server.client import NextcloudClient -from nextcloud_mcp_server.config import get_settings +from nextcloud_mcp_server.config import ( + DeploymentMode, + get_deployment_mode, + get_settings, +) + +logger = logging.getLogger(__name__) async def get_client(ctx: Context) -> NextcloudClient: """ Get the appropriate Nextcloud client based on authentication mode. - ADR-005 compliant implementation supporting two modes: - 1. BasicAuth mode: Returns shared client from lifespan context - 2. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default): - Token already contains both MCP and Nextcloud audiences - use directly - 3. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true): - Exchange MCP token for Nextcloud token via RFC 8693 + ADR-016 compliant implementation supporting three deployment modes: + + 1. Smithery stateless mode (SMITHERY_DEPLOYMENT=true): + Create client from session configuration (nextcloud_url, username, app_password) + No persistent state - client created per-request from Smithery session config. + + 2. BasicAuth mode: Returns shared client from lifespan context + + 3. OAuth mode: + a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default): + Token already contains both MCP and Nextcloud audiences - use directly + b. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true): + Exchange MCP token for Nextcloud token via RFC 8693 SECURITY: Token passthrough has been REMOVED. All OAuth modes validate proper token audiences per MCP Security Best Practices specification. @@ -24,7 +40,7 @@ async def get_client(ctx: Context) -> NextcloudClient: by the MCP server via @require_scopes decorator, not by the IdP. This function automatically detects the authentication mode by checking - the type of the lifespan context. + the deployment mode and type of the lifespan context. Args: ctx: MCP request context @@ -34,6 +50,7 @@ async def get_client(ctx: Context) -> NextcloudClient: Raises: AttributeError: If context doesn't contain expected data + ValueError: If Smithery mode but session config is missing required fields Example: ```python @@ -43,6 +60,12 @@ async def get_client(ctx: Context) -> NextcloudClient: return await client.capabilities() ``` """ + deployment_mode = get_deployment_mode() + + # ADR-016: Smithery stateless mode - create client from session config + if deployment_mode == DeploymentMode.SMITHERY_STATELESS: + return _get_client_from_session_config(ctx) + settings = get_settings() lifespan_ctx = ctx.request_context.lifespan_context @@ -75,3 +98,83 @@ async def get_client(ctx: Context) -> NextcloudClient: f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. " f"Type: {type(lifespan_ctx)}" ) + + +def _get_client_from_session_config(ctx: Context) -> NextcloudClient: + """ + Create NextcloudClient from Smithery session configuration. + + ADR-016: In Smithery stateless mode, each request includes session config + with the user's Nextcloud credentials. This function creates a fresh client + for each request - no state is persisted between requests. + + Expected session config fields (from Smithery configSchema): + - nextcloud_url: str - Nextcloud instance URL (required) + - username: str - Nextcloud username (required) + - app_password: str - Nextcloud app password (required) + + Args: + ctx: MCP request context containing session_config + + Returns: + NextcloudClient configured with session credentials + + Raises: + ValueError: If required session config fields are missing + """ + # Access session config from context + # In Smithery mode, this is populated from URL parameters + session_config = getattr(ctx, "session_config", None) + + if session_config is None: + raise ValueError( + "Session configuration required in Smithery mode. " + "Ensure nextcloud_url, username, and app_password are provided." + ) + + # Extract required fields - support both dict and object access + if isinstance(session_config, dict): + nextcloud_url = session_config.get("nextcloud_url") + username = session_config.get("username") + app_password = session_config.get("app_password") + else: + nextcloud_url = getattr(session_config, "nextcloud_url", None) + username = getattr(session_config, "username", None) + app_password = getattr(session_config, "app_password", None) + + # Validate required fields + missing_fields = [] + if not nextcloud_url: + missing_fields.append("nextcloud_url") + if not username: + missing_fields.append("username") + if not app_password: + missing_fields.append("app_password") + + if missing_fields: + raise ValueError( + f"Missing required session config fields: {', '.join(missing_fields)}. " + f"Configure these in the Smithery connection settings." + ) + + # Type assertions after validation (for type checker) + # These are guaranteed to be str after the missing_fields check above + assert nextcloud_url is not None + assert username is not None + assert app_password is not None + + # Validate URL format + if not nextcloud_url.startswith(("http://", "https://")): + raise ValueError( + f"Invalid nextcloud_url: {nextcloud_url}. " + f"Must start with http:// or https://" + ) + + logger.debug(f"Creating Smithery client for {nextcloud_url} as {username}") + + # Create client with session credentials using BasicAuth + return NextcloudClient( + base_url=nextcloud_url, + username=username, + auth=BasicAuth(username, app_password), + ) diff --git a/nextcloud_mcp_server/smithery_main.py b/nextcloud_mcp_server/smithery_main.py new file mode 100644 index 0000000..e4f07e2 --- /dev/null +++ b/nextcloud_mcp_server/smithery_main.py @@ -0,0 +1,60 @@ +"""Smithery-specific entrypoint for stateless deployment. + +ADR-016: This entrypoint is used when deploying on Smithery's hosting platform. +It configures the server for stateless operation with per-session authentication. + +Features disabled in Smithery mode: +- Vector sync / semantic search (no persistent storage) +- Admin UI at /app (no webhooks, no vector viz) +- OAuth provisioning tools (no token storage) + +Features enabled: +- Core Nextcloud tools (notes, calendar, contacts, files, deck, tables, cookbook) +- Per-session app password authentication via Smithery configSchema +- Health check endpoints (/health/live, /health/ready) +""" + +import logging +import os + +import uvicorn + +from nextcloud_mcp_server.config import setup_logging + +logger = logging.getLogger(__name__) + + +def main(): + """Start the MCP server in Smithery stateless mode.""" + # Setup logging first + setup_logging() + + # Force stateless mode environment variables + os.environ["SMITHERY_DEPLOYMENT"] = "true" + os.environ["VECTOR_SYNC_ENABLED"] = "false" + + logger.info("Starting Nextcloud MCP Server in Smithery stateless mode") + + # Import app after setting environment variables + from nextcloud_mcp_server.app import get_app + + # Create the app with streamable-http transport (required for Smithery) + app = get_app(transport="streamable-http") + + # Smithery sets PORT environment variable + port = int(os.environ.get("PORT", 8081)) + + logger.info(f"Listening on port {port}") + + uvicorn.run( + app, + host="0.0.0.0", + port=port, + log_level="info", + # Disable access log for cleaner output + access_log=False, + ) + + +if __name__ == "__main__": + main() diff --git a/smithery.yaml b/smithery.yaml new file mode 100644 index 0000000..c97365e --- /dev/null +++ b/smithery.yaml @@ -0,0 +1,38 @@ +# Smithery configuration for Nextcloud MCP Server +# See: https://smithery.ai/docs/build/configuration +# ADR-016: Stateless deployment mode for multi-user public Nextcloud instances + +runtime: "container" + +build: + dockerfile: "Dockerfile.smithery" + dockerBuildPath: "." + +startCommand: + type: "http" + configSchema: + type: "object" + required: + - "nextcloud_url" + - "username" + - "app_password" + properties: + nextcloud_url: + type: "string" + title: "Nextcloud URL" + description: "Your Nextcloud instance URL (e.g., https://cloud.example.com). Must be publicly accessible." + pattern: "^https?://.+" + username: + type: "string" + title: "Username" + description: "Your Nextcloud username" + minLength: 1 + app_password: + type: "string" + title: "App Password" + description: "Nextcloud app password. Generate at Settings > Security > App passwords. Do NOT use your main password." + minLength: 1 + exampleConfig: + nextcloud_url: "https://cloud.example.com" + username: "alice" + app_password: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"