From 482ef89a738663e227815cec6944f19ec6d1efd8 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 17:13:18 +0100 Subject: [PATCH 01/11] docs: Add ADR-016 for Smithery stateless deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add architecture decision record for supporting Smithery-hosted MCP server in a stateless mode for multi-user public Nextcloud instances. Key decisions: - New SMITHERY_STATELESS deployment mode alongside SELF_HOSTED - Session-based configuration (nextcloud_url, username, app_password) - Feature subset excluding semantic search and background sync - Admin UI (/app) excluded in Smithery mode - Per-request client creation from session config This enables users to try the MCP server without self-hosting infrastructure while supporting multiple Nextcloud instances. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/ADR-016-smithery-stateless-deployment.md | 492 ++++++++++++++++++ 1 file changed, 492 insertions(+) create mode 100644 docs/ADR-016-smithery-stateless-deployment.md 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) From f93d65099250653da9f4f102f1bda354ce84013e Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 17:30:42 +0100 Subject: [PATCH 02/11] feat: Implement ADR-016 Smithery stateless deployment mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for Smithery hosted deployment with stateless operation: - Add DeploymentMode enum with SELF_HOSTED and SMITHERY_STATELESS modes - Add get_deployment_mode() to detect mode from SMITHERY_DEPLOYMENT env var - Update get_client() to create per-request clients from session config - Add conditional tool registration (skip semantic search in Smithery mode) - Add conditional /app admin UI mounting (skip in Smithery mode) - Create smithery.yaml with configSchema for user credentials - Create Dockerfile.smithery for minimal stateless container - Create smithery_main.py entrypoint for Smithery deployment In Smithery mode: - Users provide nextcloud_url, username, app_password via session config - Each request creates a fresh NextcloudClient (no state between requests) - Semantic search tools are disabled (no vector database) - Admin UI (/app) is disabled (no webhooks, vector viz) All existing self-hosted functionality remains unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Dockerfile.smithery | 45 +++++++ nextcloud_mcp_server/app.py | 169 ++++++++++++++------------ nextcloud_mcp_server/config.py | 29 +++++ nextcloud_mcp_server/context.py | 119 ++++++++++++++++-- nextcloud_mcp_server/smithery_main.py | 60 +++++++++ smithery.yaml | 38 ++++++ 6 files changed, 377 insertions(+), 83 deletions(-) create mode 100644 Dockerfile.smithery create mode 100644 nextcloud_mcp_server/smithery_main.py create mode 100644 smithery.yaml 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" From 432ab73741493e6d0b7b1897bcc1d1594aa43887 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 17:32:20 +0100 Subject: [PATCH 03/11] build: Add missing deps --- Dockerfile.smithery | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile.smithery b/Dockerfile.smithery index b80bde5..6664d17 100644 --- a/Dockerfile.smithery +++ b/Dockerfile.smithery @@ -19,6 +19,12 @@ 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 dependency files first (for better layer caching) COPY pyproject.toml uv.lock ./ From ce55b239e2cb8b0f70c47d97bcf81fe81350e197 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 17:33:12 +0100 Subject: [PATCH 04/11] build: Fix Dockerfile.smithery --- Dockerfile.smithery | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.smithery b/Dockerfile.smithery index 6664d17..141f0af 100644 --- a/Dockerfile.smithery +++ b/Dockerfile.smithery @@ -23,7 +23,7 @@ COPY --from=ghcr.io/astral-sh/uv:0.9.10@sha256:29bd45092ea8902c0bbb7f0a338f0494a # 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 \ + git # Copy dependency files first (for better layer caching) COPY pyproject.toml uv.lock ./ From a272e7cbabb2c889614401d91c3dc76bf5b60787 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 17:35:16 +0100 Subject: [PATCH 05/11] build: Fix Dockerfile.smithery --- Dockerfile.smithery | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/Dockerfile.smithery b/Dockerfile.smithery index 141f0af..e1d5c83 100644 --- a/Dockerfile.smithery +++ b/Dockerfile.smithery @@ -25,16 +25,10 @@ COPY --from=ghcr.io/astral-sh/uv:0.9.10@sha256:29bd45092ea8902c0bbb7f0a338f0494a RUN apt update && apt install --no-install-recommends --no-install-suggests -y \ git -# Copy dependency files first (for better layer caching) -COPY pyproject.toml uv.lock ./ +# Copy project files +COPY . . -# 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 +RUN uv sync --locked --no-dev --no-editable --no-cache # Set Smithery mode environment variables ENV SMITHERY_DEPLOYMENT=true From 8d29ce01229c2e8c03fd4346c520156b28b7b5e9 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 17:48:53 +0100 Subject: [PATCH 06/11] fix: Add Smithery lifespan and auth mode detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SmitheryAppContext dataclass for stateless mode - Add app_lifespan_smithery() with minimal lifespan (no shared state) - Update is_oauth_mode() to detect Smithery mode and return BasicAuth - Use Smithery lifespan when SMITHERY_DEPLOYMENT=true - Add .well-known/mcp-config endpoint for config discovery - Skip document processors in Smithery mode (not enabled) Fixes startup issues in Smithery mode where missing env credentials would incorrectly trigger OAuth mode. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- nextcloud_mcp_server/app.py | 97 +++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index b31a595..02caa9d 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -266,17 +266,57 @@ 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 + + +@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") @@ -860,8 +900,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") @@ -922,8 +963,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(): @@ -1361,6 +1407,51 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): routes.append(Route("/health/ready", health_ready, methods=["GET"])) logger.info("Health check endpoints enabled: /health/live, /health/ready") + # ADR-016: MCP config discovery endpoint for Smithery + # Returns the session configuration schema that Smithery uses to render the config UI + def mcp_config(request): + """MCP configuration discovery endpoint. + + Returns the JSON schema for session configuration parameters. + Used by Smithery to render the configuration form for users. + """ + return JSONResponse( + { + "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", + }, + } + ) + + routes.append(Route("/.well-known/mcp-config", mcp_config, methods=["GET"])) + logger.info("MCP config discovery endpoint enabled: /.well-known/mcp-config") + # Add test webhook endpoint (for development/testing) routes.append( Route("/webhooks/nextcloud", handle_nextcloud_webhook, methods=["POST"]) From b8dc413b736bbb75ab918f7f5a6c6b0ff41cf32b Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 18:05:33 +0100 Subject: [PATCH 07/11] feat: Add Smithery CLI deployment support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add smithery package as dependency - Create smithery_server.py with @smithery.server() decorator - Add SmitheryConfigSchema for session config (nextcloud_url, username, app_password) - Add [tool.smithery] section to pyproject.toml - Remove manual .well-known/mcp-config endpoint (Smithery handles this) Smithery CLI will automatically: - Extract config schema from the decorated function - Handle session config parsing from query parameters - Make config accessible via ctx.session_config in tools 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- nextcloud_mcp_server/app.py | 45 -------------- nextcloud_mcp_server/smithery_server.py | 82 +++++++++++++++++++++++++ pyproject.toml | 4 ++ uv.lock | 37 +++++++++++ 4 files changed, 123 insertions(+), 45 deletions(-) create mode 100644 nextcloud_mcp_server/smithery_server.py diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 02caa9d..930fddc 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -1407,51 +1407,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): routes.append(Route("/health/ready", health_ready, methods=["GET"])) logger.info("Health check endpoints enabled: /health/live, /health/ready") - # ADR-016: MCP config discovery endpoint for Smithery - # Returns the session configuration schema that Smithery uses to render the config UI - def mcp_config(request): - """MCP configuration discovery endpoint. - - Returns the JSON schema for session configuration parameters. - Used by Smithery to render the configuration form for users. - """ - return JSONResponse( - { - "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", - }, - } - ) - - routes.append(Route("/.well-known/mcp-config", mcp_config, methods=["GET"])) - logger.info("MCP config discovery endpoint enabled: /.well-known/mcp-config") - # Add test webhook endpoint (for development/testing) routes.append( Route("/webhooks/nextcloud", handle_nextcloud_webhook, methods=["POST"]) diff --git a/nextcloud_mcp_server/smithery_server.py b/nextcloud_mcp_server/smithery_server.py new file mode 100644 index 0000000..7c3de61 --- /dev/null +++ b/nextcloud_mcp_server/smithery_server.py @@ -0,0 +1,82 @@ +"""Smithery server factory for stateless deployment. + +ADR-016: This module provides a server factory function decorated with +@smithery.server() for Smithery CLI deployment. Session configuration +is automatically handled by Smithery and accessible via ctx.session_config. +""" + +import logging +import os + +from mcp.server.fastmcp import FastMCP +from pydantic import BaseModel, Field +from smithery.decorators import smithery + +from nextcloud_mcp_server.server import ( + configure_calendar_tools, + configure_contacts_tools, + configure_cookbook_tools, + configure_deck_tools, + configure_notes_tools, + configure_sharing_tools, + configure_tables_tools, + configure_webdav_tools, +) + +logger = logging.getLogger(__name__) + + +class SmitheryConfigSchema(BaseModel): + """Configuration schema for Smithery session. + + These fields are collected by Smithery's configuration UI and passed + to the server with each request as session_config. + """ + + nextcloud_url: str = Field( + ..., + description="Your Nextcloud instance URL (e.g., https://cloud.example.com)", + ) + username: str = Field( + ..., + description="Your Nextcloud username", + ) + app_password: str = Field( + ..., + description="Nextcloud app password (Settings > Security > App passwords)", + ) + + +@smithery.server(config_schema=SmitheryConfigSchema) +def create_server(): + """Create and return a FastMCP server instance for Smithery deployment. + + This function is called by Smithery CLI to create the server. + Session configuration is automatically handled by Smithery and + accessible via ctx.session_config in tool handlers. + """ + # Force Smithery mode + os.environ["SMITHERY_DEPLOYMENT"] = "true" + os.environ["VECTOR_SYNC_ENABLED"] = "false" + + logger.info("Creating Nextcloud MCP Server for Smithery deployment") + + # Import lifespan after setting env vars + from nextcloud_mcp_server.app import app_lifespan_smithery + + # Create FastMCP server with Smithery lifespan + mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_smithery) + + # Register all core tools (semantic search is skipped in Smithery mode) + configure_notes_tools(mcp) + configure_tables_tools(mcp) + configure_webdav_tools(mcp) + configure_sharing_tools(mcp) + configure_calendar_tools(mcp) + configure_contacts_tools(mcp) + configure_cookbook_tools(mcp) + configure_deck_tools(mcp) + + logger.info("Smithery server configured with core Nextcloud tools") + + return mcp diff --git a/pyproject.toml b/pyproject.toml index 5481b0e..9c089b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "pymupdf>=1.26.6", "pymupdf4llm>=0.2.2", "pymupdf-layout>=1.26.6", + "smithery>=0.4.4", ] classifiers = [ "Development Status :: 4 - Beta", @@ -132,3 +133,6 @@ name = "testpypi" url = "https://test.pypi.org/simple/" publish-url = "https://test.pypi.org/legacy/" explicit = true + +[tool.smithery] +server = "nextcloud_mcp_server.smithery_server:create_server" diff --git a/uv.lock b/uv.lock index 75307d2..3561db3 100644 --- a/uv.lock +++ b/uv.lock @@ -195,6 +195,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] +[[package]] +name = "art" +version = "6.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/7d/7d80509bbd19fb747edef94ba487dbadd2747944774ccc0528ad0d005a36/art-6.5.tar.gz", hash = "sha256:a98d77b42c278697ec6cf4b5bdcdfd997f6b2425332da078d4e31e31377d1844", size = 672902, upload-time = "2025-04-12T17:02:20.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/29/57b06fdb3abdf52c621d3ca3caea735e2db4c8d48288ebd26af448e8e247/art-6.5-py3-none-any.whl", hash = "sha256:70706408144c45c666caab690627d5c74aea7b6c7ce8cc968408ddeef8d84afd", size = 610382, upload-time = "2025-04-12T17:02:21.97Z" }, +] + [[package]] name = "asgiref" version = "3.10.0" @@ -1967,6 +1976,7 @@ dependencies = [ { name = "python-json-logger" }, { name = "pythonvcard4" }, { name = "qdrant-client" }, + { name = "smithery" }, ] [package.dev-dependencies] @@ -2015,6 +2025,7 @@ requires-dist = [ { name = "python-json-logger", specifier = ">=3.2.0" }, { name = "pythonvcard4", specifier = ">=0.2.0" }, { name = "qdrant-client", specifier = ">=1.7.0" }, + { name = "smithery", specifier = ">=0.4.4" }, ] [package.metadata.requires-dev] @@ -3554,6 +3565,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smithery" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "art" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "toml" }, + { name = "typer" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/75/d0b0fc1a5c10a20f3e01cefd98276ccbe7b44d74eeb6551bd2f42d8b4768/smithery-0.4.4.tar.gz", hash = "sha256:18ae19af8405e6476ca4984036d4460822ec1647ad2262addb4909d03387d671", size = 17396, upload-time = "2025-10-24T15:47:44.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/54/a088fa621c9c76a72a70079fac42b822137a1609ec08525168bd8e9f415d/smithery-0.4.4-py3-none-any.whl", hash = "sha256:883d060b3ecc73a2972019760e342f5a04b62edf18e3bc03594a41851319808a", size = 25372, upload-time = "2025-10-24T15:47:43.045Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -3675,6 +3703,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/46/e33a8c93907b631a99377ef4c5f817ab453d0b34f93529421f42ff559671/tokenizers-0.22.1-cp39-abi3-win_amd64.whl", hash = "sha256:65fd6e3fb11ca1e78a6a93602490f134d1fdeb13bcef99389d5102ea318ed138", size = 2674684, upload-time = "2025-09-19T09:49:24.953Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tomli" version = "2.3.0" From 706a15f0bc60f9e38b763c77ffedd0b721de0b4f Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 18:22:55 +0100 Subject: [PATCH 08/11] fix(smithery): Use container runtime pattern for config discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-016: For container runtime deployment, Smithery does not auto-generate the .well-known/mcp-config endpoint like it does for Python CLI runtime. Changes: - Remove [tool.smithery] from pyproject.toml (not used in container mode) - Remove smithery_server.py (Python CLI runtime specific) - Add .well-known/mcp-config endpoint to return JSON Schema config - Add SmitheryConfigMiddleware to extract config from URL query params - Use ContextVar to pass session config to tool handlers The container runtime passes config as URL query parameters to /mcp: GET /mcp?nextcloud_url=...&username=...&app_password=... Tested: - All 164 unit tests passing - Docker container builds successfully - .well-known/mcp-config returns valid JSON Schema - Health endpoints working 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Dockerfile.smithery | 3 +- README.md | 1 + nextcloud_mcp_server/app.py | 126 ++++++++++++++++++++++++ nextcloud_mcp_server/context.py | 27 +++-- nextcloud_mcp_server/smithery_server.py | 82 --------------- pyproject.toml | 4 +- 6 files changed, 142 insertions(+), 101 deletions(-) delete mode 100644 nextcloud_mcp_server/smithery_server.py diff --git a/Dockerfile.smithery b/Dockerfile.smithery index e1d5c83..9be7ac6 100644 --- a/Dockerfile.smithery +++ b/Dockerfile.smithery @@ -41,5 +41,4 @@ EXPOSE 8081 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"] +CMD ["/app/.venv/bin/smithery-main"] diff --git a/README.md b/README.md index af876cd..a4c049a 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.** diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 930fddc..5191c51 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, @@ -276,6 +279,102 @@ class SmitheryAppContext: 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 +SMITHERY_CONFIG_SCHEMA = { + "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, + }, + }, +} + + +# 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]: """ @@ -1413,6 +1512,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) @@ -1754,4 +1873,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/context.py b/nextcloud_mcp_server/context.py index 5ab47d1..994d8a7 100644 --- a/nextcloud_mcp_server/context.py +++ b/nextcloud_mcp_server/context.py @@ -108,13 +108,16 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient: 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 containing session_config + ctx: MCP request context (not used directly for Smithery config) Returns: NextcloudClient configured with session credentials @@ -122,25 +125,21 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient: 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) + # 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." + "Ensure nextcloud_url, username, and app_password are provided as URL query parameters." ) - # 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) + # 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 = [] diff --git a/nextcloud_mcp_server/smithery_server.py b/nextcloud_mcp_server/smithery_server.py deleted file mode 100644 index 7c3de61..0000000 --- a/nextcloud_mcp_server/smithery_server.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Smithery server factory for stateless deployment. - -ADR-016: This module provides a server factory function decorated with -@smithery.server() for Smithery CLI deployment. Session configuration -is automatically handled by Smithery and accessible via ctx.session_config. -""" - -import logging -import os - -from mcp.server.fastmcp import FastMCP -from pydantic import BaseModel, Field -from smithery.decorators import smithery - -from nextcloud_mcp_server.server import ( - configure_calendar_tools, - configure_contacts_tools, - configure_cookbook_tools, - configure_deck_tools, - configure_notes_tools, - configure_sharing_tools, - configure_tables_tools, - configure_webdav_tools, -) - -logger = logging.getLogger(__name__) - - -class SmitheryConfigSchema(BaseModel): - """Configuration schema for Smithery session. - - These fields are collected by Smithery's configuration UI and passed - to the server with each request as session_config. - """ - - nextcloud_url: str = Field( - ..., - description="Your Nextcloud instance URL (e.g., https://cloud.example.com)", - ) - username: str = Field( - ..., - description="Your Nextcloud username", - ) - app_password: str = Field( - ..., - description="Nextcloud app password (Settings > Security > App passwords)", - ) - - -@smithery.server(config_schema=SmitheryConfigSchema) -def create_server(): - """Create and return a FastMCP server instance for Smithery deployment. - - This function is called by Smithery CLI to create the server. - Session configuration is automatically handled by Smithery and - accessible via ctx.session_config in tool handlers. - """ - # Force Smithery mode - os.environ["SMITHERY_DEPLOYMENT"] = "true" - os.environ["VECTOR_SYNC_ENABLED"] = "false" - - logger.info("Creating Nextcloud MCP Server for Smithery deployment") - - # Import lifespan after setting env vars - from nextcloud_mcp_server.app import app_lifespan_smithery - - # Create FastMCP server with Smithery lifespan - mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_smithery) - - # Register all core tools (semantic search is skipped in Smithery mode) - configure_notes_tools(mcp) - configure_tables_tools(mcp) - configure_webdav_tools(mcp) - configure_sharing_tools(mcp) - configure_calendar_tools(mcp) - configure_contacts_tools(mcp) - configure_cookbook_tools(mcp) - configure_deck_tools(mcp) - - logger.info("Smithery server configured with core Nextcloud tools") - - return mcp diff --git a/pyproject.toml b/pyproject.toml index 9c089b4..e46f367 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,12 +127,10 @@ dev = [ [project.scripts] nextcloud-mcp-server = "nextcloud_mcp_server.cli:run" +smithery-main = "nextcloud_mcp_server.smithery_main:main" [[tool.uv.index]] name = "testpypi" url = "https://test.pypi.org/simple/" publish-url = "https://test.pypi.org/legacy/" explicit = true - -[tool.smithery] -server = "nextcloud_mcp_server.smithery_server:create_server" From 39fba49cfeb9a207dcf5a556655de9aa12ce84d8 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 18:28:05 +0100 Subject: [PATCH 09/11] fix(smithery): Add JSON Schema metadata to mcp-config endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add proper JSON Schema metadata fields per Smithery documentation: - $schema: JSON Schema draft-07 - $id: Schema identifier URL - title: Human-readable title - description: Schema description - x-query-style: "flat" (no nested objects in our schema) - additionalProperties: false 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- nextcloud_mcp_server/app.py | 7 +++++++ pyproject.toml | 1 - uv.lock | 37 ------------------------------------- 3 files changed, 7 insertions(+), 38 deletions(-) diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 5191c51..dd3357a 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -281,7 +281,13 @@ class SmitheryAppContext: # 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": { @@ -304,6 +310,7 @@ SMITHERY_CONFIG_SCHEMA = { "minLength": 1, }, }, + "additionalProperties": False, } diff --git a/pyproject.toml b/pyproject.toml index e46f367..7a06c01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ dependencies = [ "pymupdf>=1.26.6", "pymupdf4llm>=0.2.2", "pymupdf-layout>=1.26.6", - "smithery>=0.4.4", ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/uv.lock b/uv.lock index 3561db3..75307d2 100644 --- a/uv.lock +++ b/uv.lock @@ -195,15 +195,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] -[[package]] -name = "art" -version = "6.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/7d/7d80509bbd19fb747edef94ba487dbadd2747944774ccc0528ad0d005a36/art-6.5.tar.gz", hash = "sha256:a98d77b42c278697ec6cf4b5bdcdfd997f6b2425332da078d4e31e31377d1844", size = 672902, upload-time = "2025-04-12T17:02:20.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/29/57b06fdb3abdf52c621d3ca3caea735e2db4c8d48288ebd26af448e8e247/art-6.5-py3-none-any.whl", hash = "sha256:70706408144c45c666caab690627d5c74aea7b6c7ce8cc968408ddeef8d84afd", size = 610382, upload-time = "2025-04-12T17:02:21.97Z" }, -] - [[package]] name = "asgiref" version = "3.10.0" @@ -1976,7 +1967,6 @@ dependencies = [ { name = "python-json-logger" }, { name = "pythonvcard4" }, { name = "qdrant-client" }, - { name = "smithery" }, ] [package.dev-dependencies] @@ -2025,7 +2015,6 @@ requires-dist = [ { name = "python-json-logger", specifier = ">=3.2.0" }, { name = "pythonvcard4", specifier = ">=0.2.0" }, { name = "qdrant-client", specifier = ">=1.7.0" }, - { name = "smithery", specifier = ">=0.4.4" }, ] [package.metadata.requires-dev] @@ -3565,23 +3554,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "smithery" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "art" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "toml" }, - { name = "typer" }, - { name = "uvicorn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/75/d0b0fc1a5c10a20f3e01cefd98276ccbe7b44d74eeb6551bd2f42d8b4768/smithery-0.4.4.tar.gz", hash = "sha256:18ae19af8405e6476ca4984036d4460822ec1647ad2262addb4909d03387d671", size = 17396, upload-time = "2025-10-24T15:47:44.543Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/54/a088fa621c9c76a72a70079fac42b822137a1609ec08525168bd8e9f415d/smithery-0.4.4-py3-none-any.whl", hash = "sha256:883d060b3ecc73a2972019760e342f5a04b62edf18e3bc03594a41851319808a", size = 25372, upload-time = "2025-10-24T15:47:43.045Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -3703,15 +3675,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/46/e33a8c93907b631a99377ef4c5f817ab453d0b34f93529421f42ff559671/tokenizers-0.22.1-cp39-abi3-win_amd64.whl", hash = "sha256:65fd6e3fb11ca1e78a6a93602490f134d1fdeb13bcef99389d5102ea318ed138", size = 2674684, upload-time = "2025-09-19T09:49:24.953Z" }, ] -[[package]] -name = "toml" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, -] - [[package]] name = "tomli" version = "2.3.0" From 7b22e5be0f636f463207f39866a55b6a7fec0e87 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 19:06:25 +0100 Subject: [PATCH 10/11] build: add smithery image to docker compose --- docker-compose.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index f6137d6..dfab90d 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 From 3e3d38696cd79fb9a46c74b56c4a666af70daf64 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 19:38:11 +0100 Subject: [PATCH 11/11] docs(smithery): Make Smithery the primary Quick Start option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize README to promote Smithery as the fastest way to get started: - Quick Start now features Smithery one-click deployment - Docker instructions moved to separate "Docker (Self-Hosted)" section - Added note about Smithery's stateless mode limitations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a4c049a..50c1bfd 100644 --- a/README.md +++ b/README.md @@ -18,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 @@ -38,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)