diff --git a/Dockerfile.smithery b/Dockerfile.smithery new file mode 100644 index 0000000..9be7ac6 --- /dev/null +++ b/Dockerfile.smithery @@ -0,0 +1,44 @@ +# 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/ + +# 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 af876cd..50c1bfd 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ # 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.** @@ -17,7 +18,20 @@ This is a **dedicated standalone MCP server** designed for external MCP clients ## Quick Start -Get up and running in 60 seconds using Docker: +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) + +For full features including semantic search, run with Docker: ```bash # 1. Create a minimal configuration @@ -37,12 +51,11 @@ curl http://127.0.0.1:8000/health/ready # 4. Connect to the endpoint http://127.0.0.1:8000/sse -# 4. Or with --transport streamable-http +# Or with --transport streamable-http http://127.0.0.1:8000/mcp ``` **Next Steps:** -- Create an app password in Nextcloud: Settings → Security → Devices & sessions - Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.) - See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes) diff --git a/docker-compose.yml b/docker-compose.yml index accd430..f133b09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -224,6 +224,26 @@ services: - keycloak-tokens:/app/data - keycloak-oauth-storage:/app/.oauth + # Smithery stateless deployment mode (ADR-016) + # Test with: docker compose --profile smithery up smithery + # Then: curl http://localhost:8081/.well-known/mcp-config + smithery: + build: + context: . + dockerfile: Dockerfile.smithery + restart: always + depends_on: + app: + condition: service_healthy + ports: + - 127.0.0.1:8081:8081 + environment: + - SMITHERY_DEPLOYMENT=true + - VECTOR_SYNC_ENABLED=false + - PORT=8081 + profiles: + - smithery + qdrant: image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4 restart: always diff --git a/docs/ADR-016-smithery-stateless-deployment.md b/docs/ADR-016-smithery-stateless-deployment.md new file mode 100644 index 0000000..6162bf5 --- /dev/null +++ b/docs/ADR-016-smithery-stateless-deployment.md @@ -0,0 +1,492 @@ +# ADR-016: Smithery Stateless Deployment for Multi-User Public Nextcloud Instances + +**Status:** Proposed +**Date:** 2025-01-22 +**Deciders:** Development Team +**Related:** ADR-004 (OAuth), ADR-007 (Background Vector Sync), ADR-015 (Unified Provider) + +## Context + +[Smithery](https://smithery.ai) is a hosting platform and marketplace for MCP servers that provides: + +- **Discovery**: Marketplace listing for MCP servers +- **Hosting**: Containerized deployment with auto-scaling +- **Authentication UI**: OAuth flow presentation for users +- **Session Configuration**: Per-user settings passed via URL parameters +- **Observability**: Usage logs and monitoring + +### Current Architecture Limitations + +The current nextcloud-mcp-server architecture assumes a **self-hosted deployment** with: + +1. **Persistent Infrastructure** + - Qdrant vector database for semantic search + - Background sync worker for content indexing + - Refresh token storage for offline access + +2. **Single-Tenant Configuration** + - Environment variables configure one Nextcloud instance + - `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` + - Or OAuth with a single IdP + +3. **Stateful Operations** + - Vector sync maintains index state across requests + - Token storage persists between sessions + +### Smithery Hosting Constraints + +Smithery-hosted containers are **stateless by design**: + +- No persistent storage between requests +- No background workers or cron jobs +- No databases (Qdrant, Redis, etc.) +- Containers may be recycled at any time +- Configuration passed per-session via URL parameters + +### Opportunity + +Many users have **publicly accessible Nextcloud instances** and want to: + +1. Try the MCP server without self-hosting infrastructure +2. Connect multiple users to different Nextcloud instances +3. Use basic Nextcloud tools without semantic search +4. Benefit from Smithery's discovery and OAuth UI + +## Decision + +Implement a **stateless deployment mode** for Smithery that: + +1. **Disables stateful features** (vector sync, semantic search) +2. **Creates clients per-session** from Smithery configuration +3. **Supports multiple Nextcloud instances** via session config +4. **Provides a useful subset of tools** that work without infrastructure + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Smithery-Hosted Stateless Mode │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ MCP Client Smithery │ +│ (Cursor, Claude) Infrastructure │ +│ │ │ │ +│ │ 1. Connect │ │ +│ ├───────────────────────────►│ │ +│ │ │ │ +│ │ 2. Config UI │ │ +│ │◄───────────────────────────┤ User enters: │ +│ │ (Smithery presents) │ - nextcloud_url │ +│ │ │ - auth_mode (basic/oauth) │ +│ │ │ - credentials │ +│ │ 3. Tool call │ │ +│ ├───────────────────────────►│ │ +│ │ + session config │ │ +│ │ │ │ +│ │ ┌───────┴───────┐ │ +│ │ │ MCP Server │ │ +│ │ │ Container │ │ +│ │ │ │ │ +│ │ │ 4. Create │ │ +│ │ │ client │ │ +│ │ │ from │ │ +│ │ │ config │ │ +│ │ │ │ │ │ +│ │ │ ▼ │ │ +│ │ │ 5. Call │ │ +│ │ │ Nextcloud │───────► User's Nextcloud │ +│ │ │ API │ Instance │ +│ │ │ │ │ │ +│ │ │ ▼ │ │ +│ │ 6. Response │ Return result │ │ +│ │◄───────────────────┤ │ │ +│ │ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Session Configuration Schema + +```python +from pydantic import BaseModel, Field + +class SmitheryConfigSchema(BaseModel): + """Configuration schema for Smithery session.""" + + # Required: Nextcloud instance + nextcloud_url: str = Field( + ..., + description="Your Nextcloud instance URL (e.g., https://cloud.example.com)" + ) + + # Authentication mode + auth_mode: str = Field( + "app_password", + description="Authentication method: 'app_password' or 'oauth'" + ) + + # App Password authentication (recommended for Smithery) + username: str | None = Field( + None, + description="Nextcloud username (required for app_password auth)" + ) + app_password: str | None = Field( + None, + description="Nextcloud app password (Settings → Security → App passwords)" + ) + + # OAuth authentication (advanced) + # When auth_mode='oauth', Smithery handles the OAuth flow + # and passes the access token automatically +``` + +### Feature Matrix + +| Feature | Self-Hosted | Smithery Stateless | +|---------|-------------|-------------------| +| **Notes** | | | +| List/Search notes | ✓ | ✓ | +| Get/Create/Update notes | ✓ | ✓ | +| Semantic search | ✓ | ✗ | +| **Calendar** | | | +| List calendars | ✓ | ✓ | +| Get/Create events | ✓ | ✓ | +| **Contacts** | | | +| List address books | ✓ | ✓ | +| Search/Get contacts | ✓ | ✓ | +| **Files (WebDAV)** | | | +| List/Download files | ✓ | ✓ | +| Upload files | ✓ | ✓ | +| Search files | ✓ | ✓ (keyword only) | +| **Deck** | | | +| List boards/cards | ✓ | ✓ | +| Create/Update cards | ✓ | ✓ | +| **Tables** | | | +| List/Query tables | ✓ | ✓ | +| Create/Update rows | ✓ | ✓ | +| **Cookbook** | | | +| List/Get recipes | ✓ | ✓ | +| **Semantic Search** | | | +| Vector search | ✓ | ✗ | +| RAG answers | ✓ | ✗ | +| **Background Sync** | | | +| Auto-indexing | ✓ | ✗ | +| Webhook sync | ✓ | ✗ | +| **Admin UI (`/app`)** | | | +| Vector sync status | ✓ | ✗ | +| Vector visualization | ✓ | ✗ | +| Webhook management | ✓ | ✗ | +| Session management | ✓ | ✗ | + +### Implementation + +#### 1. Deployment Mode Detection + +```python +# nextcloud_mcp_server/config.py + +class DeploymentMode(Enum): + SELF_HOSTED = "self_hosted" # Full features, env-based config + SMITHERY_STATELESS = "smithery" # Stateless, session-based config + +def get_deployment_mode() -> DeploymentMode: + """Detect deployment mode from environment.""" + if os.getenv("SMITHERY_DEPLOYMENT") == "true": + return DeploymentMode.SMITHERY_STATELESS + return DeploymentMode.SELF_HOSTED +``` + +#### 2. Session-Based Client Factory + +```python +# nextcloud_mcp_server/context.py + +async def get_client(ctx: Context) -> NextcloudClient: + """Get NextcloudClient - from session config or environment.""" + + mode = get_deployment_mode() + + if mode == DeploymentMode.SMITHERY_STATELESS: + # Create client from Smithery session config + config = ctx.session_config + if not config: + raise McpError("Session configuration required") + + return NextcloudClient( + base_url=config.nextcloud_url, + username=config.username, + password=config.app_password, + ) + else: + # Existing behavior: from environment or OAuth context + return await _get_client_from_context(ctx) +``` + +#### 3. Conditional Tool Registration + +```python +# nextcloud_mcp_server/app.py + +def create_mcp_server(mode: DeploymentMode) -> FastMCP: + """Create MCP server with mode-appropriate tools.""" + + mcp = FastMCP("Nextcloud MCP") + + # Always register core tools + configure_notes_tools(mcp) + configure_calendar_tools(mcp) + configure_contacts_tools(mcp) + configure_webdav_tools(mcp) + configure_deck_tools(mcp) + configure_tables_tools(mcp) + configure_cookbook_tools(mcp) + + # Only register stateful tools in self-hosted mode + if mode == DeploymentMode.SELF_HOSTED: + configure_semantic_tools(mcp) # Requires Qdrant + register_oauth_tools(mcp) # Requires token storage + + return mcp +``` + +#### 4. Exclude Admin UI Routes + +The `/app` admin UI should **not be installed** in Smithery mode because: + +- **Vector sync status** - No vector sync in stateless mode +- **Vector visualization** - No Qdrant to visualize +- **Webhook management** - No webhook sync without background workers +- **Session management** - No persistent sessions to manage + +```python +# nextcloud_mcp_server/app.py + +def create_app(mode: DeploymentMode) -> Starlette: + """Create Starlette app with mode-appropriate routes.""" + + routes = [ + Route("/health/live", health_live, methods=["GET"]), + Route("/health/ready", health_ready, methods=["GET"]), + ] + + # Only mount admin UI in self-hosted mode + if mode == DeploymentMode.SELF_HOSTED: + browser_app = create_browser_app() + routes.append( + Route("/app", lambda r: RedirectResponse("/app/", status_code=307)) + ) + routes.append(Mount("/app", app=browser_app)) + logger.info("Admin UI mounted at /app") + else: + logger.info("Admin UI disabled in Smithery stateless mode") + + # Mount FastMCP at root + mcp_app = create_mcp_server(mode).streamable_http_app() + routes.append(Mount("/", app=mcp_app)) + + return Starlette(routes=routes, lifespan=starlette_lifespan) +``` + +**Endpoints by Mode:** + +| Endpoint | Self-Hosted | Smithery | +|----------|-------------|----------| +| `/mcp` | ✓ | ✓ | +| `/health/live` | ✓ | ✓ | +| `/health/ready` | ✓ | ✓ | +| `/.well-known/mcp-config` | ✓ | ✓ | +| `/app` | ✓ | ✗ | +| `/app/vector-sync/status` | ✓ | ✗ | +| `/app/vector-viz` | ✓ | ✗ | +| `/app/webhooks` | ✓ | ✗ | + +#### 5. Smithery Integration Files + +**smithery.yaml:** +```yaml +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)" + username: + type: "string" + title: "Username" + description: "Your Nextcloud username" + app_password: + type: "string" + title: "App Password" + description: "Generate at Settings → Security → App passwords" + exampleConfig: + nextcloud_url: "https://cloud.example.com" + username: "alice" + app_password: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx" +``` + +**Dockerfile.smithery:** +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + +# Copy project files +COPY pyproject.toml uv.lock ./ +COPY nextcloud_mcp_server ./nextcloud_mcp_server + +# Install dependencies (without vector/semantic extras) +RUN uv sync --frozen --no-dev + +# Set Smithery mode +ENV SMITHERY_DEPLOYMENT=true +ENV VECTOR_SYNC_ENABLED=false + +# Smithery sets PORT=8081 +EXPOSE 8081 + +CMD ["uv", "run", "python", "-m", "nextcloud_mcp_server.smithery_main"] +``` + +**nextcloud_mcp_server/smithery_main.py:** +```python +"""Smithery-specific entrypoint for stateless deployment.""" + +import os +import uvicorn +from starlette.middleware.cors import CORSMiddleware + +from nextcloud_mcp_server.app import create_mcp_server +from nextcloud_mcp_server.config import DeploymentMode + +def main(): + # Force stateless mode + os.environ["SMITHERY_DEPLOYMENT"] = "true" + os.environ["VECTOR_SYNC_ENABLED"] = "false" + + mcp = create_mcp_server(DeploymentMode.SMITHERY_STATELESS) + app = mcp.streamable_http_app() + + # Add CORS for browser-based clients + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["*"], + expose_headers=["mcp-session-id", "mcp-protocol-version"], + ) + + # Smithery sets PORT environment variable + port = int(os.environ.get("PORT", 8081)) + uvicorn.run(app, host="0.0.0.0", port=port) + +if __name__ == "__main__": + main() +``` + +### Security Considerations + +1. **App Passwords over User Passwords** + - Smithery config encourages app passwords (revocable, scoped) + - Documentation guides users to create dedicated app passwords + - App passwords can be revoked without changing main password + +2. **HTTPS Required** + - `nextcloud_url` must be HTTPS for production use + - Validation rejects HTTP URLs in Smithery mode + +3. **No Credential Storage** + - Credentials exist only for request duration + - No server-side persistence of user credentials + - Smithery handles secure config transmission + +4. **Scope Limitation** + - Stateless mode cannot access offline_access + - No background operations on user's behalf + - Clear user expectation: tools work during session only + +### Migration Path + +Users can start with Smithery stateless mode and migrate to self-hosted: + +1. **Try on Smithery** → Basic tools, no setup +2. **Self-host for semantic search** → Add Qdrant, enable vector sync +3. **Full deployment** → Background sync, webhooks, multi-user OAuth + +## Consequences + +### Positive + +1. **Lower barrier to entry** - Users can try without infrastructure +2. **Multi-user support** - Each session connects to different Nextcloud +3. **Smithery ecosystem** - Discovery, observability, OAuth UI +4. **Clear feature tiers** - Stateless (simple) vs self-hosted (full) + +### Negative + +1. **No semantic search** - Key differentiator unavailable on Smithery +2. **Per-request auth** - Credentials sent with each request +3. **No offline access** - Cannot perform background operations +4. **Maintenance burden** - Two deployment modes to support + +### Neutral + +1. **Feature subset** - May encourage users to self-host for full features +2. **Documentation needs** - Clear guidance on mode differences required + +## Alternatives Considered + +### 1. External MCP Only + +**Approach:** Only support self-hosted external MCP registration on Smithery. + +**Rejected because:** +- Higher barrier to entry for new users +- Misses opportunity for Smithery marketplace visibility +- Users want to try before committing to infrastructure + +### 2. Embedded Vector DB (SQLite-vec) + +**Approach:** Use SQLite with vector extensions for per-request indexing. + +**Rejected because:** +- No persistence between requests anyway +- Indexing latency too high for synchronous requests +- Complexity without benefit in stateless context + +### 3. External Vector DB Service + +**Approach:** Connect to Pinecone/Weaviate Cloud from Smithery container. + +**Rejected because:** +- Adds external dependency and cost +- Per-user collections require complex multi-tenancy +- Sync still impossible without background workers + +### 4. Hybrid: Smithery + User's Qdrant + +**Approach:** User provides their own Qdrant URL in session config. + +**Considered for future:** +- Could enable semantic search for advanced users +- Adds complexity to session config +- Sync still requires external trigger (manual or webhook) + +## References + +- [Smithery Documentation](https://smithery.ai/docs) +- [Smithery Session Configuration](https://smithery.ai/docs/build/session-config) +- [Smithery External MCPs](https://smithery.ai/docs/build/external) +- [MCP Streamable HTTP Transport](https://modelcontextprotocol.io/docs/concepts/transports) +- [Nextcloud App Passwords](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#app-passwords) diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index f532bd4..dd3357a 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -3,6 +3,7 @@ import os import time from collections.abc import AsyncIterator from contextlib import AsyncExitStack, asynccontextmanager +from contextvars import ContextVar from dataclasses import dataclass from typing import TYPE_CHECKING, Optional @@ -25,6 +26,8 @@ from starlette.middleware.cors import CORSMiddleware from starlette.responses import JSONResponse, RedirectResponse from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles +from starlette.types import ASGIApp, Receive, Send +from starlette.types import Scope as StarletteScope from nextcloud_mcp_server.auth import ( InsufficientScopeError, @@ -36,6 +39,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, ) @@ -264,17 +269,160 @@ 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 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 + from urllib.parse import parse_qs + + 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") + + 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 + - AND we are NOT in Smithery stateless mode - Or explicitly enabled via configuration Returns: True if OAuth mode, False if BasicAuth mode """ + # ADR-016: Smithery stateless mode uses per-request BasicAuth from session config + # It's not OAuth mode even though env credentials aren't set + deployment_mode = get_deployment_mode() + if deployment_mode == DeploymentMode.SMITHERY_STATELESS: + logger.info( + "BasicAuth mode (Smithery stateless - credentials from session config)" + ) + return False + username = os.getenv("NEXTCLOUD_USERNAME") password = os.getenv("NEXTCLOUD_PASSWORD") @@ -858,8 +1006,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): "OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)" ) - # Determine authentication mode + # Determine authentication mode and deployment mode oauth_enabled = is_oauth_mode() + deployment_mode = get_deployment_mode() if oauth_enabled: logger.info("Configuring MCP server for OAuth mode") @@ -920,8 +1069,13 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): auth=auth_settings, ) else: - logger.info("Configuring MCP server for BasicAuth mode") - mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic) + # ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise + if deployment_mode == DeploymentMode.SMITHERY_STATELESS: + logger.info("Configuring MCP server for Smithery stateless mode") + mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_smithery) + 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(): @@ -957,8 +1111,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: @@ -1361,6 +1519,26 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): ) logger.info("Test webhook endpoint enabled: /webhooks/nextcloud") + # 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) @@ -1491,85 +1669,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)) @@ -1689,4 +1880,11 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = 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") + return 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..994d8a7 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,82 @@ 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. + + 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 + + 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), + ) 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/pyproject.toml b/pyproject.toml index 3e9096d..89c97c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ dev = [ [project.scripts] nextcloud-mcp-server = "nextcloud_mcp_server.cli:run" +smithery-main = "nextcloud_mcp_server.smithery_main:main" [[tool.uv.index]] name = "testpypi" 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"