diff --git a/Dockerfile.smithery b/Dockerfile.smithery deleted file mode 100644 index 04783763..00000000 --- a/Dockerfile.smithery +++ /dev/null @@ -1,44 +0,0 @@ -# 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:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c - -WORKDIR /app - -# Install uv for fast dependency management -COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /uv /uvx /bin/ - -# Install dependencies -# 1. git (required for caldav dependency from git) -# 2. sqlite for development with token db -RUN apt update && apt install --no-install-recommends --no-install-suggests -y \ - git - -# Copy project files -COPY . . - -RUN uv sync --locked --no-dev --no-editable --no-cache - -# 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()" - -CMD ["/app/.venv/bin/smithery-main"] diff --git a/README.md b/README.md index 1d4a5a7a..b965bfd4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ # Nextcloud MCP Server [![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server) -[![smithery badge](https://smithery.ai/badge/@cbcoutinho/nextcloud-mcp-server)](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server) **A production-ready MCP server that connects AI assistants to your Nextcloud instance.** @@ -16,20 +15,7 @@ This is a **dedicated standalone MCP server** designed for external MCP clients > [!NOTE] > **Looking for AI features inside Nextcloud?** Nextcloud also provides [Context Agent](https://github.com/nextcloud/context_agent), which powers the Assistant app and runs as an ExApp inside Nextcloud. See [docs/comparison-context-agent.md](docs/comparison-context-agent.md) for a detailed comparison of use cases. -## Quick Start - -The fastest way to get started is via [Smithery](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server) - no Docker or self-hosting required: - -1. Visit the [Smithery marketplace page](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server) -2. Click "Deploy" and configure: - - **Nextcloud URL**: Your Nextcloud instance (e.g., `https://cloud.example.com`) - - **Username**: Your Nextcloud username - - **App Password**: Generate one in Nextcloud → Settings → Security → Devices & sessions - -> [!NOTE] -> Smithery runs in stateless mode without semantic search. For full features, use [Docker](#docker-self-hosted) or see [ADR-016](docs/ADR-016-smithery-stateless-deployment.md). - -## Docker (Self-Hosted) +## Quick Start (Docker) For full features including semantic search, run with Docker: diff --git a/docs/ADR-016-smithery-stateless-deployment.md b/docs/ADR-016-smithery-stateless-deployment.md index 6162bf55..745b982d 100644 --- a/docs/ADR-016-smithery-stateless-deployment.md +++ b/docs/ADR-016-smithery-stateless-deployment.md @@ -1,7 +1,8 @@ # ADR-016: Smithery Stateless Deployment for Multi-User Public Nextcloud Instances -**Status:** Proposed +**Status:** Deprecated (removed in v0.66.0) **Date:** 2025-01-22 +**Deprecated:** 2026-03-22 — Smithery deployment mode has been removed. Smithery sunsetted its free tier and third-party hosting conflicts with the project's privacy-first design. See ADR-022 for rationale. **Deciders:** Development Team **Related:** ADR-004 (OAuth), ADR-007 (Background Vector Sync), ADR-015 (Unified Provider) diff --git a/docs/ADR-021-configuration-consolidation.md b/docs/ADR-021-configuration-consolidation.md index e96aee09..065ff936 100644 --- a/docs/ADR-021-configuration-consolidation.md +++ b/docs/ADR-021-configuration-consolidation.md @@ -89,7 +89,7 @@ Add `MCP_DEPLOYMENT_MODE` environment variable to remove detection ambiguity: MCP_DEPLOYMENT_MODE=oauth_single_audience # Valid values: single_user_basic, multi_user_basic, -# oauth_single_audience, oauth_token_exchange, smithery +# oauth_single_audience, oauth_token_exchange ``` **Detection logic**: diff --git a/docs/ADR-022-deployment-mode-consolidation.md b/docs/ADR-022-deployment-mode-consolidation.md index ee6cde63..9386fad6 100644 --- a/docs/ADR-022-deployment-mode-consolidation.md +++ b/docs/ADR-022-deployment-mode-consolidation.md @@ -13,8 +13,6 @@ The Nextcloud MCP Server currently supports five distinct deployment modes (ADR- 2. **Multi-User BasicAuth** - HTTP header credential pass-through 3. **OAuth Single-Audience** - Multi-audience token validation 4. **OAuth Token Exchange** - RFC 8693 delegation -5. **Smithery Stateless** - Session URL parameters (free tier sunsetting March 2026) - This complexity creates several problems: ### Maintenance Burden diff --git a/docs/auth-flows.md b/docs/auth-flows.md index d1647515..b038d2e3 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -10,7 +10,6 @@ This document provides a unified reference for authentication flows across all d | [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token | | [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token | | [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token | -| [Smithery Stateless](#5-smithery-stateless) | Session parameters | Not supported | N/A | ## Communication Patterns @@ -303,59 +302,6 @@ Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-ser --- -### 5. Smithery Stateless - -**Use Case:** Multi-tenant SaaS deployment via Smithery platform. Fully stateless. - -Enabled by `SMITHERY_DEPLOYMENT=true`. - -#### MCP Client → MCP Server → Nextcloud - -``` -MCP Client MCP Server Nextcloud - │ │ │ - │── SSE Connect ─────────────▶│ │ - │ ?nextcloud_url=... │ │ - │ &username=... │ │ - │ &app_password=... │ │ - │ │── SmitheryConfigMiddleware │ - │ │ Extract URL params │ - │ │ │ - │── MCP Request ─────────────▶│ │ - │ (no Authorization header) │ │ - │ │── Create per-request ─────▶│ - │ │ NextcloudClient │ - │ │ │ - │ │── HTTP + BasicAuth ───────▶│ - │ │ (from session params) │ - │ │◀── API Response ───────────│ - │◀── Tool Result ─────────────│ │ -``` - -**Key characteristics:** -- Configuration passed via URL query parameters (Smithery `configSchema`) -- No persistent state - client created fresh per request -- No OAuth infrastructure -- No background sync support (stateless) -- No admin UI available - -**Required session parameters:** -- `nextcloud_url`: Nextcloud instance URL -- `username`: Nextcloud username -- `app_password`: Nextcloud app password - -**Implementation:** `context.py:108-184` - `_get_client_from_session_config()` creates client from session params - -#### Background Sync - -Not supported. Smithery mode is fully stateless with no credential storage. - -#### Astrolabe Integration - -Not applicable. Smithery deployments don't integrate with Astrolabe. - ---- - ## Configuration Quick Reference ### Single-User BasicAuth @@ -404,12 +350,6 @@ TOKEN_ENCRYPTION_KEY=<32-byte-key> TOKEN_STORAGE_DB=/data/tokens.db ``` -### Smithery Stateless -```bash -SMITHERY_DEPLOYMENT=true -# All other config comes from session URL parameters -``` - --- ## Related Documentation diff --git a/docs/configuration-migration-v2.md b/docs/configuration-migration-v2.md index e7fde7bd..101c3bbf 100644 --- a/docs/configuration-migration-v2.md +++ b/docs/configuration-migration-v2.md @@ -313,7 +313,6 @@ if ENABLE_SEMANTIC_SEARCH == true: | Multi-User BasicAuth | ✅ | ✅ Yes | | OAuth Single-Audience | ✅ | ✅ Yes | | OAuth Token Exchange | ✅ | ✅ Yes | -| Smithery Stateless | N/A (not supported) | N/A | ### When to Explicitly Set ENABLE_BACKGROUND_OPERATIONS @@ -366,7 +365,6 @@ NEXTCLOUD_HOST=https://nextcloud.example.com | `multi_user_basic` | Multi-user with BasicAuth pass-through | | `oauth_single_audience` | Multi-user OAuth (recommended) | | `oauth_token_exchange` | Multi-user OAuth with token exchange | -| `smithery` | Smithery platform deployment | ### Mode Detection Priority @@ -377,7 +375,7 @@ When `MCP_DEPLOYMENT_MODE` is set: When `MCP_DEPLOYMENT_MODE` is NOT set: 1. ✅ Auto-detection runs (existing behavior) -2. ✅ Priority: Smithery → Token Exchange → Multi-User BasicAuth → Single-User BasicAuth → OAuth Single-Audience +2. ✅ Priority: Token Exchange → Multi-User BasicAuth → Single-User BasicAuth → OAuth Single-Audience --- diff --git a/docs/configuration.md b/docs/configuration.md index c965aa52..26e3f300 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -42,7 +42,6 @@ MCP_DEPLOYMENT_MODE=oauth_single_audience - `multi_user_basic` - Multi-user with BasicAuth pass-through - `oauth_single_audience` - Multi-user OAuth (recommended) - `oauth_token_exchange` - Multi-user OAuth with token exchange -- `smithery` - Smithery platform deployment **Benefits:** - ✅ Clear which mode is active diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dbdb444d..8178660a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -69,7 +69,7 @@ ENABLE_BACKGROUND_OPERATIONS=true **Symptom:** ``` -ValueError: Invalid MCP_DEPLOYMENT_MODE: 'oauth'. Valid values: single_user_basic, multi_user_basic, oauth_single_audience, oauth_token_exchange, smithery +ValueError: Invalid MCP_DEPLOYMENT_MODE: 'oauth'. Valid values: single_user_basic, multi_user_basic, oauth_single_audience, oauth_token_exchange ``` **Cause:** Invalid value for `MCP_DEPLOYMENT_MODE`. @@ -82,7 +82,6 @@ MCP_DEPLOYMENT_MODE=single_user_basic # Single-user with username/passw MCP_DEPLOYMENT_MODE=multi_user_basic # Multi-user BasicAuth MCP_DEPLOYMENT_MODE=oauth_single_audience # OAuth (recommended) MCP_DEPLOYMENT_MODE=oauth_token_exchange # OAuth with token exchange -MCP_DEPLOYMENT_MODE=smithery # Smithery deployment ``` Or remove `MCP_DEPLOYMENT_MODE` to use automatic detection. diff --git a/env.sample b/env.sample index 06524f24..07a0d247 100644 --- a/env.sample +++ b/env.sample @@ -4,7 +4,7 @@ # Optional: Explicitly declare deployment mode (ADR-021) # If not set, mode is auto-detected from other settings # Valid values: single_user_basic, multi_user_basic, oauth_single_audience, -# oauth_token_exchange, smithery +# oauth_token_exchange # # Recommendation: Set this for clarity and to catch configuration errors early #MCP_DEPLOYMENT_MODE=oauth_single_audience @@ -119,16 +119,6 @@ NEXTCLOUD_PASSWORD= # Optional features (semantic search, document processing): # See "Optional Features" section below -# ============================================ -# SMITHERY STATELESS MODE -# ============================================ -# Stateless multi-tenant deployment for Smithery platform -# Configuration comes from session URL parameters -# No persistent storage, no OAuth, no vector sync -# -# Required: None (all config from session URL) -# This mode is activated automatically when deployed to Smithery - # ============================================ # OPTIONAL FEATURES (All Deployment Modes) # ============================================ diff --git a/nextcloud_mcp_server/api/management.py b/nextcloud_mcp_server/api/management.py index e3894270..9fe022fa 100644 --- a/nextcloud_mcp_server/api/management.py +++ b/nextcloud_mcp_server/api/management.py @@ -218,8 +218,6 @@ async def get_server_status(request: Request) -> JSONResponse: auth_mode = "multi_user_basic" elif mode == AuthMode.SINGLE_USER_BASIC: auth_mode = "basic" - elif mode == AuthMode.SMITHERY_STATELESS: - auth_mode = "smithery" else: auth_mode = "unknown" diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 5d602eaf..733de031 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -8,10 +8,9 @@ import time import traceback from collections.abc import AsyncIterator from contextlib import AsyncExitStack, asynccontextmanager -from contextvars import ContextVar from dataclasses import dataclass from typing import Optional, cast -from urllib.parse import parse_qs, urlparse +from urllib.parse import urlparse import anyio import click @@ -95,7 +94,6 @@ from nextcloud_mcp_server.auth.webhook_routes import ( ) from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.config import ( - DeploymentMode, Settings, get_document_processor_config, get_settings, @@ -357,67 +355,6 @@ class OAuthAppContext: ) -@dataclass -class SmitheryAppContext: - """Application context for Smithery stateless mode. - - ADR-016: No shared client - clients created per-request from session config. - """ - - pass # No shared state needed - everything comes from session config - - -# ADR-016: Smithery config schema for container runtime -# This schema is served at /.well-known/mcp-config for Smithery discovery -# See: https://smithery.ai/docs/build/session-config -SMITHERY_CONFIG_SCHEMA = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://server.smithery.ai/nextcloud-mcp-server/.well-known/mcp-config", - "title": "Nextcloud MCP Server Configuration", - "description": "Configuration for connecting to your Nextcloud instance via app password authentication", - "x-query-style": "flat", # Our schema has no nested objects, so flat style works - "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, - }, - }, - "additionalProperties": False, -} - - -# ADR-016: Context variable to hold Smithery session config per-request -# This is set by SmitheryConfigMiddleware and accessed in context.py -_smithery_session_config: ContextVar[dict[str, str] | None] = ContextVar( - "smithery_session_config" -) -_smithery_session_config.set(None) # Set initial value - - -def get_smithery_session_config() -> dict | None: - """Get the current Smithery session config from context variable. - - Used by context.py to access config extracted from URL query parameters. - """ - return _smithery_session_config.get() - - class BasicAuthMiddleware: """Middleware to extract BasicAuth credentials from Authorization header. @@ -462,76 +399,6 @@ class BasicAuthMiddleware: await self.app(scope, receive, send) -class SmitheryConfigMiddleware: - """Middleware to extract Smithery config from URL query parameters. - - ADR-016: For container runtime, Smithery passes configuration as URL query - parameters to the /mcp endpoint. This middleware extracts those parameters - and stores them in a context variable for access in tools. - - Configuration parameters: - - nextcloud_url: Nextcloud instance URL - - username: Nextcloud username - - app_password: Nextcloud app password - - The extracted config is stored in a ContextVar and can be accessed via - get_smithery_session_config() in context.py. - """ - - def __init__(self, app: ASGIApp): - self.app = app - - async def __call__( - self, scope: StarletteScope, receive: Receive, send: Send - ) -> None: - if scope["type"] == "http": - # Extract config from query parameters - query_string = scope.get("query_string", b"").decode("utf-8") - params = parse_qs(query_string) - - # Build session config from query parameters - # Smithery uses dot notation for nested objects, but our schema is flat - session_config = {} - for key in ["nextcloud_url", "username", "app_password"]: - if key in params: - # parse_qs returns lists, take first value - session_config[key] = params[key][0] - - # Store in context variable for access by context.py - if session_config: - _smithery_session_config.set(session_config) - logger.debug( - f"Smithery config extracted: nextcloud_url={session_config.get('nextcloud_url')}, " - f"username={session_config.get('username')}" - ) - - try: - await self.app(scope, receive, send) - finally: - # Clear context variable after request - _smithery_session_config.set(None) - - -@asynccontextmanager -async def app_lifespan_smithery(server: FastMCP) -> AsyncIterator[SmitheryAppContext]: - """ - Manage application lifecycle for Smithery stateless mode. - - ADR-016: Minimal lifespan with no shared state. - - No shared Nextcloud client (created per-request from session config) - - No vector sync (disabled in Smithery mode) - - No persistent storage (stateless deployment) - - No document processors (not enabled in Smithery mode) - """ - logger.info("Starting MCP server in Smithery stateless mode") - logger.info("Clients will be created per-request from session config") - - try: - yield SmitheryAppContext() - finally: - logger.info("Shutting down Smithery stateless mode") - - async def load_oauth_client_credentials( nextcloud_host: str, registration_endpoint: str | None ) -> tuple[str, str]: @@ -1169,12 +1036,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE, ) - deployment_mode = ( - DeploymentMode.SMITHERY_STATELESS - if mode == AuthMode.SMITHERY_STATELESS - else DeploymentMode.SELF_HOSTED - ) - # Log hybrid authentication status for multi-user BasicAuth with offline access if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access: logger.info( @@ -1386,20 +1247,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = enable_dns_rebinding_protection=False ), ) - elif mode == AuthMode.SMITHERY_STATELESS: - logger.info("Configuring MCP server for Smithery stateless mode") - # json_response=True returns plain JSON-RPC instead of SSE format, - # required for Smithery scanner compatibility - mcp = FastMCP( - "Nextcloud MCP", - lifespan=app_lifespan_smithery, - json_response=True, - # Disable DNS rebinding protection for containerized deployments (k8s, Docker) - # MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names - transport_security=TransportSecuritySettings( - enable_dns_rebinding_protection=False - ), - ) else: # BasicAuth modes (single-user or multi-user) logger.info(f"Configuring MCP server for {mode.value} mode") @@ -1448,10 +1295,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = ) # Register semantic search tools (cross-app feature) - # ADR-016: Skip in Smithery stateless mode (no vector database) - if deployment_mode == DeploymentMode.SMITHERY_STATELESS: - logger.info("Skipping semantic search tools (Smithery stateless mode)") - elif settings.vector_sync_enabled: + if settings.vector_sync_enabled: logger.info("Configuring semantic search tools (vector sync enabled)") configure_semantic_tools(mcp) else: @@ -2103,9 +1947,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = checks["auth_mode"] = "basic" checks["auth_configured"] = "error: credentials not set" is_ready = False - elif mode == AuthMode.SMITHERY_STATELESS: - checks["auth_mode"] = "smithery" - checks["auth_configured"] = "ok" # Check Qdrant status if using network mode (external Qdrant service) # In-memory and persistent modes use embedded Qdrant, no external service to check @@ -2187,20 +2028,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = logger.info("Test webhook endpoint enabled: /webhooks/nextcloud") # Add management API endpoints for Nextcloud PHP app - # Tier 1: Public endpoints (no auth required) - available in all non-Smithery modes + # Tier 1: Public endpoints (no auth required) # These let Astrolabe show basic server status even in single-user BasicAuth mode - if deployment_mode != DeploymentMode.SMITHERY_STATELESS: - routes.append(Route("/api/v1/status", get_server_status, methods=["GET"])) - routes.append( - Route( - "/api/v1/vector-sync/status", - get_vector_sync_status, - methods=["GET"], - ) - ) - logger.info( - "Public management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status" + routes.append(Route("/api/v1/status", get_server_status, methods=["GET"])) + routes.append( + Route( + "/api/v1/vector-sync/status", + get_vector_sync_status, + methods=["GET"], ) + ) + logger.info( + "Public management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status" + ) # Tier 2+: Authenticated management endpoints (OAuth required) # Available in: OAuth modes OR multi-user BasicAuth with offline access @@ -2286,26 +2126,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = "/api/v1/webhooks, /api/v1/pdf-preview" ) - # ADR-016: Add Smithery well-known config endpoint for container runtime discovery - if deployment_mode == DeploymentMode.SMITHERY_STATELESS: - - def smithery_mcp_config(request): - """Smithery MCP configuration endpoint. - - Returns JSON Schema for Smithery's configuration UI. - This endpoint is required for Smithery container runtime discovery. - """ - return JSONResponse(SMITHERY_CONFIG_SCHEMA) - - routes.append( - Route( - "/.well-known/mcp-config", - smithery_mcp_config, - methods=["GET"], - ) - ) - logger.info("Smithery config endpoint enabled: /.well-known/mcp-config") - # Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons. # Metrics are served on dedicated port via setup_metrics() (default: 9090) @@ -2453,80 +2273,72 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = ) # Add user info routes (available in both BasicAuth and OAuth modes) - # ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks) - if deployment_mode != DeploymentMode.SMITHERY_STATELESS: - # 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"], - ), - ] + # 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 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}") - - browser_app = Starlette(routes=browser_routes) - browser_app.add_middleware( - AuthenticationMiddleware, # type: ignore[invalid-argument-type] - backend=SessionAuthBackend(oauth_enabled=oauth_enabled), + # 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}") - # Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps) - routes.append( - Route("/app", lambda request: RedirectResponse("/app/", status_code=307)) - ) + browser_app = Starlette(routes=browser_routes) + browser_app.add_middleware( + AuthenticationMiddleware, # type: ignore[invalid-argument-type] + backend=SessionAuthBackend(oauth_enabled=oauth_enabled), + ) - # 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") + # 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)) @@ -2648,13 +2460,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = logger.info("WWW-Authenticate scope challenge handler enabled") - # ADR-016: Apply SmitheryConfigMiddleware in Smithery stateless mode - # This must be the outermost middleware to extract config from URL query parameters - # before any other middleware processes the request - if deployment_mode == DeploymentMode.SMITHERY_STATELESS: - app = SmitheryConfigMiddleware(app) - logger.info("SmitheryConfigMiddleware enabled for query parameter config") - # Apply BasicAuthMiddleware for multi-user BasicAuth pass-through mode if settings.enable_multi_user_basic_auth: app = BasicAuthMiddleware(app) diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 2c5c0685..6405c938 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -4,37 +4,8 @@ import os import socket import ssl from dataclasses import dataclass -from enum import Enum from typing import Any - -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, @@ -168,7 +139,7 @@ class Settings: # Deployment mode (ADR-021: explicit mode selection) # Optional: If not set, mode is auto-detected from other settings # Valid values: single_user_basic, multi_user_basic, oauth_single_audience, - # oauth_token_exchange, smithery + # oauth_token_exchange deployment_mode: str | None = None # OAuth/OIDC settings @@ -434,15 +405,10 @@ def _is_multi_user_mode() -> bool: Single-user modes are: - Single-user BasicAuth (username and password both set) - - Smithery Stateless (SMITHERY_DEPLOYMENT=true) Returns: True if multi-user mode detected """ - # Smithery is always single-user (stateless) - if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true": - return False - # Multi-user BasicAuth explicitly enabled if os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true": return True diff --git a/nextcloud_mcp_server/config_validators.py b/nextcloud_mcp_server/config_validators.py index 6d7909d1..d425367f 100644 --- a/nextcloud_mcp_server/config_validators.py +++ b/nextcloud_mcp_server/config_validators.py @@ -9,7 +9,6 @@ See ADR-020 for detailed architecture and deployment mode documentation. """ import logging -import os from dataclasses import dataclass from enum import Enum @@ -28,7 +27,6 @@ class AuthMode(Enum): MULTI_USER_BASIC = "multi_user_basic" OAUTH_SINGLE_AUDIENCE = "oauth_single" OAUTH_TOKEN_EXCHANGE = "oauth_exchange" - SMITHERY_STATELESS = "smithery" @dataclass @@ -199,25 +197,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { "MCP tokens are separate from Nextcloud tokens. " "Server exchanges MCP token for Nextcloud token on each request.", ), - AuthMode.SMITHERY_STATELESS: ModeRequirements( - required=[], # All config from session URL params - optional=[], - forbidden=[ - "nextcloud_host", - "nextcloud_username", - "nextcloud_password", - "enable_multi_user_basic_auth", - "enable_token_exchange", - "enable_offline_access", - "vector_sync_enabled", - "oidc_client_id", - "oidc_client_secret", - ], - conditional={}, - description="Stateless multi-tenant deployment for Smithery platform. " - "Configuration comes from session URL parameters. " - "No persistent storage, no OAuth, no vector sync.", - ), } @@ -226,11 +205,10 @@ def detect_auth_mode(settings: Settings) -> AuthMode: Mode detection priority (ADR-021): 0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021 - 1. Smithery (explicit flag) - 2. Token exchange (most specific OAuth mode) - 3. Multi-user BasicAuth - 4. Single-user BasicAuth - 5. OAuth single-audience (default OAuth mode) + 1. Token exchange (most specific OAuth mode) + 2. Multi-user BasicAuth + 3. Single-user BasicAuth + 4. OAuth single-audience (default OAuth mode) Args: settings: Application settings @@ -254,7 +232,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode: "multi_user_basic": AuthMode.MULTI_USER_BASIC, "oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE, "oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE, - "smithery": AuthMode.SMITHERY_STATELESS, } if mode_str not in mode_map: @@ -269,12 +246,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode: return explicit_mode # Auto-detection (existing behavior) - # Check for Smithery mode (explicit environment variable) - # Note: This checks the environment directly, not settings - # because Smithery mode has no settings-based config - if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true": - return AuthMode.SMITHERY_STATELESS - # Check for token exchange (most specific OAuth mode) if settings.enable_token_exchange: return AuthMode.OAUTH_TOKEN_EXCHANGE diff --git a/nextcloud_mcp_server/context.py b/nextcloud_mcp_server/context.py index 29045aab..6a380ea7 100644 --- a/nextcloud_mcp_server/context.py +++ b/nextcloud_mcp_server/context.py @@ -12,11 +12,7 @@ from nextcloud_mcp_server.auth.context_helper import ( from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError from nextcloud_mcp_server.auth.storage import get_shared_storage from nextcloud_mcp_server.client import NextcloudClient -from nextcloud_mcp_server.config import ( - DeploymentMode, - get_deployment_mode, - get_settings, -) +from nextcloud_mcp_server.config import get_settings logger = logging.getLogger(__name__) @@ -25,15 +21,11 @@ async def get_client(ctx: Context) -> NextcloudClient: """ Get the appropriate Nextcloud client based on authentication mode. - ADR-016 compliant implementation supporting three deployment modes: + Supports the following 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. + 1. BasicAuth mode: Returns shared client from lifespan context - 2. BasicAuth mode: Returns shared client from lifespan context - - 3. OAuth mode: + 2. 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): @@ -46,7 +38,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 deployment mode and type of the lifespan context. + the type of the lifespan context. Args: ctx: MCP request context @@ -56,7 +48,6 @@ 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 @@ -66,12 +57,6 @@ 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() # Multi-user BasicAuth pass-through mode - extract credentials from request @@ -111,85 +96,6 @@ async def get_client(ctx: Context) -> NextcloudClient: ) -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. - - For container runtime, config is extracted from URL query parameters by - SmitheryConfigMiddleware and stored in a context variable. - - 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 (not used directly for Smithery config) - - Returns: - NextcloudClient configured with session credentials - - Raises: - ValueError: If required session config fields are missing - """ - # ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware) - from nextcloud_mcp_server.app import get_smithery_session_config # noqa: PLC0415 - - session_config = get_smithery_session_config() - - if session_config is None: - raise ValueError( - "Session configuration required in Smithery mode. " - "Ensure nextcloud_url, username, and app_password are provided as URL query parameters." - ) - - # Extract required fields - config is always a dict from SmitheryConfigMiddleware - nextcloud_url = session_config.get("nextcloud_url") - username = session_config.get("username") - app_password = session_config.get("app_password") - - # 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), - ) - - def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient: """ Create NextcloudClient from BasicAuth credentials in request headers. diff --git a/nextcloud_mcp_server/smithery_main.py b/nextcloud_mcp_server/smithery_main.py deleted file mode 100644 index 4741b056..00000000 --- a/nextcloud_mcp_server/smithery_main.py +++ /dev/null @@ -1,60 +0,0 @@ -"""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 # noqa: PLC0415 - - # 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/pyproject.toml b/pyproject.toml index 2fb5aeb7..a3fb8ce3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,7 +142,6 @@ dev = [ [project.scripts] nextcloud-mcp-server = "nextcloud_mcp_server.cli:cli" -smithery-main = "nextcloud_mcp_server.smithery_main:main" [[tool.uv.index]] name = "testpypi" diff --git a/smithery.yaml b/smithery.yaml deleted file mode 100644 index c97365e9..00000000 --- a/smithery.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# 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" diff --git a/tests/unit/test_config_validators.py b/tests/unit/test_config_validators.py index 824135a5..a31bda70 100644 --- a/tests/unit/test_config_validators.py +++ b/tests/unit/test_config_validators.py @@ -22,14 +22,6 @@ from nextcloud_mcp_server.config_validators import ( class TestModeDetection: """Test auth mode detection from configuration.""" - def test_smithery_mode_detection(self): - """Test Smithery mode is detected from environment variable.""" - settings = Settings() - - with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}): - mode = detect_auth_mode(settings) - assert mode == AuthMode.SMITHERY_STATELESS - def test_token_exchange_mode_detection(self): """Test token exchange mode is detected.""" settings = Settings( @@ -70,20 +62,6 @@ class TestModeDetection: mode = detect_auth_mode(settings) assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE - def test_mode_priority_smithery_over_all(self): - """Test Smithery mode has highest priority.""" - settings = Settings( - nextcloud_host="http://localhost", - nextcloud_username="admin", - nextcloud_password="password", - enable_token_exchange=True, - enable_multi_user_basic_auth=True, - ) - - with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}): - mode = detect_auth_mode(settings) - assert mode == AuthMode.SMITHERY_STATELESS - def test_mode_priority_token_exchange_over_basic(self): """Test token exchange has priority over BasicAuth.""" settings = Settings( @@ -486,57 +464,6 @@ class TestOAuthTokenExchangeValidation: assert any("nextcloud_password" in err.lower() for err in errors) -class TestSmitheryValidation: - """Test validation for Smithery stateless mode.""" - - def test_valid_empty_config(self): - """Test valid empty config for Smithery mode.""" - settings = Settings() - - with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}): - mode, errors = validate_configuration(settings) - - assert mode == AuthMode.SMITHERY_STATELESS - assert len(errors) == 0 - - def test_forbidden_nextcloud_host(self): - """Test error when NEXTCLOUD_HOST is set.""" - settings = Settings( - nextcloud_host="http://localhost", - ) - - with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}): - mode, errors = validate_configuration(settings) - - assert mode == AuthMode.SMITHERY_STATELESS - assert any("nextcloud_host" in err.lower() for err in errors) - - def test_forbidden_credentials(self): - """Test error when credentials are set.""" - settings = Settings( - nextcloud_username="admin", - nextcloud_password="password", - ) - - with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}): - mode, errors = validate_configuration(settings) - - assert mode == AuthMode.SMITHERY_STATELESS - assert any("nextcloud_username" in err.lower() for err in errors) - - def test_forbidden_vector_sync(self): - """Test error when vector sync is enabled.""" - settings = Settings( - vector_sync_enabled=True, - ) - - with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}): - mode, errors = validate_configuration(settings) - - assert mode == AuthMode.SMITHERY_STATELESS - assert any("vector_sync_enabled" in err.lower() for err in errors) - - class TestModeSummary: """Test mode summary generation.""" @@ -550,14 +477,6 @@ class TestModeSummary: assert "NEXTCLOUD_PASSWORD" in summary assert "VECTOR_SYNC_ENABLED" in summary - def test_smithery_summary(self): - """Test summary for Smithery mode.""" - summary = get_mode_summary(AuthMode.SMITHERY_STATELESS) - - assert "smithery" in summary - assert "session" in summary.lower() - assert "(none" in summary # No required config - def test_oauth_token_exchange_summary(self): """Test summary for OAuth token exchange mode.""" summary = get_mode_summary(AuthMode.OAUTH_TOKEN_EXCHANGE) @@ -898,22 +817,6 @@ class TestExplicitModeSelection: assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE - def test_explicit_smithery_mode(self): - """Test explicit smithery mode selection.""" - with patch.dict( - os.environ, - { - "MCP_DEPLOYMENT_MODE": "smithery", - }, - clear=True, - ): - from nextcloud_mcp_server.config import get_settings - - settings = get_settings() - mode = detect_auth_mode(settings) - - assert mode == AuthMode.SMITHERY_STATELESS - def test_invalid_deployment_mode_raises_error(self): """Test invalid MCP_DEPLOYMENT_MODE raises ValueError.""" with patch.dict(