feat: Implement ADR-016 Smithery stateless deployment mode

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 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-22 17:30:42 +01:00
parent 482ef89a73
commit f93d650992
6 changed files with 377 additions and 83 deletions
+45
View File
@@ -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"]
+94 -75
View File
@@ -36,6 +36,8 @@ from nextcloud_mcp_server.auth import (
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import ( from nextcloud_mcp_server.config import (
DeploymentMode,
get_deployment_mode,
get_document_processor_config, get_document_processor_config,
get_settings, 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) # Register semantic search tools (cross-app feature)
# ADR-016: Skip in Smithery stateless mode (no vector database)
settings = get_settings() 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)") logger.info("Configuring semantic search tools (vector sync enabled)")
configure_semantic_tools(mcp) configure_semantic_tools(mcp)
else: 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) # Add user info routes (available in both BasicAuth and OAuth modes)
# These require session authentication, so we wrap them in a separate app # ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
from nextcloud_mcp_server.auth.userinfo_routes import ( # These require session authentication, so we wrap them in a separate app
revoke_session, from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
user_info_html, from nextcloud_mcp_server.auth.userinfo_routes import (
vector_sync_status_fragment, revoke_session,
) user_info_html,
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",
vector_sync_status_fragment, 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) # Create a separate Starlette app for browser routes that need session auth
browser_app.add_middleware( # This prevents SessionAuthBackend from interfering with FastMCP's OAuth
AuthenticationMiddleware, # type: ignore[invalid-argument-type] browser_routes = [
backend=SessionAuthBackend(oauth_enabled=oauth_enabled), 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) # Add static files mount if directory exists
routes.append( static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
Route("/app", lambda request: RedirectResponse("/app/", status_code=307)) 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) browser_app = Starlette(routes=browser_routes)
routes.append(Mount("/app", app=browser_app)) browser_app.add_middleware(
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke") 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) # Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
routes.append(Mount("/", app=mcp_app)) routes.append(Mount("/", app=mcp_app))
+29
View File
@@ -2,8 +2,37 @@ import logging
import logging.config import logging.config
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional 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 = { LOGGING_CONFIG = {
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
+111 -8
View File
@@ -1,21 +1,37 @@
"""Helper functions for accessing context in MCP tools.""" """Helper functions for accessing context in MCP tools."""
import logging
from httpx import BasicAuth
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from nextcloud_mcp_server.client import NextcloudClient 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: async def get_client(ctx: Context) -> NextcloudClient:
""" """
Get the appropriate Nextcloud client based on authentication mode. Get the appropriate Nextcloud client based on authentication mode.
ADR-005 compliant implementation supporting two modes: ADR-016 compliant implementation supporting three deployment modes:
1. BasicAuth mode: Returns shared client from lifespan context
2. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default): 1. Smithery stateless mode (SMITHERY_DEPLOYMENT=true):
Token already contains both MCP and Nextcloud audiences - use directly Create client from session configuration (nextcloud_url, username, app_password)
3. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true): No persistent state - client created per-request from Smithery session config.
Exchange MCP token for Nextcloud token via RFC 8693
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 SECURITY: Token passthrough has been REMOVED. All OAuth modes validate
proper token audiences per MCP Security Best Practices specification. 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. by the MCP server via @require_scopes decorator, not by the IdP.
This function automatically detects the authentication mode by checking 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: Args:
ctx: MCP request context ctx: MCP request context
@@ -34,6 +50,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
Raises: Raises:
AttributeError: If context doesn't contain expected data AttributeError: If context doesn't contain expected data
ValueError: If Smithery mode but session config is missing required fields
Example: Example:
```python ```python
@@ -43,6 +60,12 @@ async def get_client(ctx: Context) -> NextcloudClient:
return await client.capabilities() 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() settings = get_settings()
lifespan_ctx = ctx.request_context.lifespan_context 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"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
f"Type: {type(lifespan_ctx)}" 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),
)
+60
View File
@@ -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()
+38
View File
@@ -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"