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:
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user