Merge pull request #342 from cbcoutinho/feature/smithery
feat: Add Smithery stateless deployment support (ADR-016)
This commit is contained in:
+276
-78
@@ -3,6 +3,7 @@ import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
@@ -25,6 +26,8 @@ from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.types import ASGIApp, Receive, Send
|
||||
from starlette.types import Scope as StarletteScope
|
||||
|
||||
from nextcloud_mcp_server.auth import (
|
||||
InsufficientScopeError,
|
||||
@@ -36,6 +39,8 @@ from nextcloud_mcp_server.auth import (
|
||||
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
get_deployment_mode,
|
||||
get_document_processor_config,
|
||||
get_settings,
|
||||
)
|
||||
@@ -264,17 +269,160 @@ class OAuthAppContext:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmitheryAppContext:
|
||||
"""Application context for Smithery stateless mode.
|
||||
|
||||
ADR-016: No shared client - clients created per-request from session config.
|
||||
"""
|
||||
|
||||
pass # No shared state needed - everything comes from session config
|
||||
|
||||
|
||||
# ADR-016: Smithery config schema for container runtime
|
||||
# This schema is served at /.well-known/mcp-config for Smithery discovery
|
||||
# See: https://smithery.ai/docs/build/session-config
|
||||
SMITHERY_CONFIG_SCHEMA = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://server.smithery.ai/nextcloud-mcp-server/.well-known/mcp-config",
|
||||
"title": "Nextcloud MCP Server Configuration",
|
||||
"description": "Configuration for connecting to your Nextcloud instance via app password authentication",
|
||||
"x-query-style": "flat", # Our schema has no nested objects, so flat style works
|
||||
"type": "object",
|
||||
"required": ["nextcloud_url", "username", "app_password"],
|
||||
"properties": {
|
||||
"nextcloud_url": {
|
||||
"type": "string",
|
||||
"title": "Nextcloud URL",
|
||||
"description": "Your Nextcloud instance URL (e.g., https://cloud.example.com). Must be publicly accessible.",
|
||||
"pattern": "^https?://.+",
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"title": "Username",
|
||||
"description": "Your Nextcloud username",
|
||||
"minLength": 1,
|
||||
},
|
||||
"app_password": {
|
||||
"type": "string",
|
||||
"title": "App Password",
|
||||
"description": "Nextcloud app password. Generate at Settings > Security > App passwords. Do NOT use your main password.",
|
||||
"minLength": 1,
|
||||
},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
# ADR-016: Context variable to hold Smithery session config per-request
|
||||
# This is set by SmitheryConfigMiddleware and accessed in context.py
|
||||
_smithery_session_config: ContextVar[dict[str, str] | None] = ContextVar(
|
||||
"smithery_session_config"
|
||||
)
|
||||
_smithery_session_config.set(None) # Set initial value
|
||||
|
||||
|
||||
def get_smithery_session_config() -> dict | None:
|
||||
"""Get the current Smithery session config from context variable.
|
||||
|
||||
Used by context.py to access config extracted from URL query parameters.
|
||||
"""
|
||||
return _smithery_session_config.get()
|
||||
|
||||
|
||||
class SmitheryConfigMiddleware:
|
||||
"""Middleware to extract Smithery config from URL query parameters.
|
||||
|
||||
ADR-016: For container runtime, Smithery passes configuration as URL query
|
||||
parameters to the /mcp endpoint. This middleware extracts those parameters
|
||||
and stores them in a context variable for access in tools.
|
||||
|
||||
Configuration parameters:
|
||||
- nextcloud_url: Nextcloud instance URL
|
||||
- username: Nextcloud username
|
||||
- app_password: Nextcloud app password
|
||||
|
||||
The extracted config is stored in a ContextVar and can be accessed via
|
||||
get_smithery_session_config() in context.py.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
self.app = app
|
||||
|
||||
async def __call__(
|
||||
self, scope: StarletteScope, receive: Receive, send: Send
|
||||
) -> None:
|
||||
if scope["type"] == "http":
|
||||
# Extract config from query parameters
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
query_string = scope.get("query_string", b"").decode("utf-8")
|
||||
params = parse_qs(query_string)
|
||||
|
||||
# Build session config from query parameters
|
||||
# Smithery uses dot notation for nested objects, but our schema is flat
|
||||
session_config = {}
|
||||
for key in ["nextcloud_url", "username", "app_password"]:
|
||||
if key in params:
|
||||
# parse_qs returns lists, take first value
|
||||
session_config[key] = params[key][0]
|
||||
|
||||
# Store in context variable for access by context.py
|
||||
if session_config:
|
||||
_smithery_session_config.set(session_config)
|
||||
logger.debug(
|
||||
f"Smithery config extracted: nextcloud_url={session_config.get('nextcloud_url')}, "
|
||||
f"username={session_config.get('username')}"
|
||||
)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
# Clear context variable after request
|
||||
_smithery_session_config.set(None)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan_smithery(server: FastMCP) -> AsyncIterator[SmitheryAppContext]:
|
||||
"""
|
||||
Manage application lifecycle for Smithery stateless mode.
|
||||
|
||||
ADR-016: Minimal lifespan with no shared state.
|
||||
- No shared Nextcloud client (created per-request from session config)
|
||||
- No vector sync (disabled in Smithery mode)
|
||||
- No persistent storage (stateless deployment)
|
||||
- No document processors (not enabled in Smithery mode)
|
||||
"""
|
||||
logger.info("Starting MCP server in Smithery stateless mode")
|
||||
logger.info("Clients will be created per-request from session config")
|
||||
|
||||
try:
|
||||
yield SmitheryAppContext()
|
||||
finally:
|
||||
logger.info("Shutting down Smithery stateless mode")
|
||||
|
||||
|
||||
def is_oauth_mode() -> bool:
|
||||
"""
|
||||
Determine if OAuth mode should be used.
|
||||
|
||||
OAuth mode is enabled when:
|
||||
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
|
||||
- AND we are NOT in Smithery stateless mode
|
||||
- Or explicitly enabled via configuration
|
||||
|
||||
Returns:
|
||||
True if OAuth mode, False if BasicAuth mode
|
||||
"""
|
||||
# ADR-016: Smithery stateless mode uses per-request BasicAuth from session config
|
||||
# It's not OAuth mode even though env credentials aren't set
|
||||
deployment_mode = get_deployment_mode()
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
logger.info(
|
||||
"BasicAuth mode (Smithery stateless - credentials from session config)"
|
||||
)
|
||||
return False
|
||||
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
|
||||
@@ -858,8 +1006,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
|
||||
)
|
||||
|
||||
# Determine authentication mode
|
||||
# Determine authentication mode and deployment mode
|
||||
oauth_enabled = is_oauth_mode()
|
||||
deployment_mode = get_deployment_mode()
|
||||
|
||||
if oauth_enabled:
|
||||
logger.info("Configuring MCP server for OAuth mode")
|
||||
@@ -920,8 +1069,13 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
auth=auth_settings,
|
||||
)
|
||||
else:
|
||||
logger.info("Configuring MCP server for BasicAuth mode")
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
||||
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
logger.info("Configuring MCP server for Smithery stateless mode")
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_smithery)
|
||||
else:
|
||||
logger.info("Configuring MCP server for BasicAuth mode")
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
||||
|
||||
@mcp.resource("nc://capabilities")
|
||||
async def nc_get_capabilities():
|
||||
@@ -957,8 +1111,12 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
)
|
||||
|
||||
# Register semantic search tools (cross-app feature)
|
||||
# ADR-016: Skip in Smithery stateless mode (no vector database)
|
||||
settings = get_settings()
|
||||
if settings.vector_sync_enabled:
|
||||
deployment_mode = get_deployment_mode()
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
logger.info("Skipping semantic search tools (Smithery stateless mode)")
|
||||
elif settings.vector_sync_enabled:
|
||||
logger.info("Configuring semantic search tools (vector sync enabled)")
|
||||
configure_semantic_tools(mcp)
|
||||
else:
|
||||
@@ -1361,6 +1519,26 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
)
|
||||
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
|
||||
|
||||
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
|
||||
def smithery_mcp_config(request):
|
||||
"""Smithery MCP configuration endpoint.
|
||||
|
||||
Returns JSON Schema for Smithery's configuration UI.
|
||||
This endpoint is required for Smithery container runtime discovery.
|
||||
"""
|
||||
return JSONResponse(SMITHERY_CONFIG_SCHEMA)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/mcp-config",
|
||||
smithery_mcp_config,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
logger.info("Smithery config endpoint enabled: /.well-known/mcp-config")
|
||||
|
||||
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
|
||||
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
|
||||
|
||||
@@ -1491,85 +1669,98 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
)
|
||||
|
||||
# Add user info routes (available in both BasicAuth and OAuth modes)
|
||||
# These require session authentication, so we wrap them in a separate app
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
revoke_session,
|
||||
user_info_html,
|
||||
vector_sync_status_fragment,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.viz_routes import (
|
||||
chunk_context_endpoint,
|
||||
vector_visualization_html,
|
||||
vector_visualization_search,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||
disable_webhook_preset,
|
||||
enable_webhook_preset,
|
||||
webhook_management_pane,
|
||||
)
|
||||
|
||||
# Create a separate Starlette app for browser routes that need session auth
|
||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||
browser_routes = [
|
||||
Route("/", user_info_html, methods=["GET"]), # /app → user info with all tabs
|
||||
Route(
|
||||
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
|
||||
), # /app/revoke → revoke_session
|
||||
# Vector sync status fragment (htmx polling)
|
||||
Route(
|
||||
"/vector-sync/status",
|
||||
# ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
|
||||
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
||||
# These require session authentication, so we wrap them in a separate app
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
revoke_session,
|
||||
user_info_html,
|
||||
vector_sync_status_fragment,
|
||||
methods=["GET"],
|
||||
), # /app/vector-sync/status
|
||||
# Vector visualization routes
|
||||
Route(
|
||||
"/vector-viz", vector_visualization_html, methods=["GET"]
|
||||
), # /app/vector-viz
|
||||
Route(
|
||||
"/vector-viz/search",
|
||||
vector_visualization_search,
|
||||
methods=["GET"],
|
||||
), # /app/vector-viz/search
|
||||
Route(
|
||||
"/chunk-context",
|
||||
chunk_context_endpoint,
|
||||
methods=["GET"],
|
||||
), # /app/chunk-context
|
||||
# Webhook management routes (admin-only)
|
||||
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /app/webhooks
|
||||
Route(
|
||||
"/webhooks/enable/{preset_id:str}", enable_webhook_preset, methods=["POST"]
|
||||
),
|
||||
Route(
|
||||
"/webhooks/disable/{preset_id:str}",
|
||||
disable_webhook_preset,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
# Add static files mount if directory exists
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
|
||||
if os.path.isdir(static_dir):
|
||||
browser_routes.append(
|
||||
Mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
)
|
||||
logger.info(f"Mounted static files from {static_dir}")
|
||||
from nextcloud_mcp_server.auth.viz_routes import (
|
||||
chunk_context_endpoint,
|
||||
vector_visualization_html,
|
||||
vector_visualization_search,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||
disable_webhook_preset,
|
||||
enable_webhook_preset,
|
||||
webhook_management_pane,
|
||||
)
|
||||
|
||||
browser_app = Starlette(routes=browser_routes)
|
||||
browser_app.add_middleware(
|
||||
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||
)
|
||||
# Create a separate Starlette app for browser routes that need session auth
|
||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||
browser_routes = [
|
||||
Route(
|
||||
"/", user_info_html, methods=["GET"]
|
||||
), # /app → user info with all tabs
|
||||
Route(
|
||||
"/revoke",
|
||||
revoke_session,
|
||||
methods=["POST"],
|
||||
name="revoke_session_endpoint",
|
||||
), # /app/revoke → revoke_session
|
||||
# Vector sync status fragment (htmx polling)
|
||||
Route(
|
||||
"/vector-sync/status",
|
||||
vector_sync_status_fragment,
|
||||
methods=["GET"],
|
||||
), # /app/vector-sync/status
|
||||
# Vector visualization routes
|
||||
Route(
|
||||
"/vector-viz", vector_visualization_html, methods=["GET"]
|
||||
), # /app/vector-viz
|
||||
Route(
|
||||
"/vector-viz/search",
|
||||
vector_visualization_search,
|
||||
methods=["GET"],
|
||||
), # /app/vector-viz/search
|
||||
Route(
|
||||
"/chunk-context",
|
||||
chunk_context_endpoint,
|
||||
methods=["GET"],
|
||||
), # /app/chunk-context
|
||||
# Webhook management routes (admin-only)
|
||||
Route(
|
||||
"/webhooks", webhook_management_pane, methods=["GET"]
|
||||
), # /app/webhooks
|
||||
Route(
|
||||
"/webhooks/enable/{preset_id:str}",
|
||||
enable_webhook_preset,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/webhooks/disable/{preset_id:str}",
|
||||
disable_webhook_preset,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||
routes.append(
|
||||
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
||||
)
|
||||
# Add static files mount if directory exists
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
|
||||
if os.path.isdir(static_dir):
|
||||
browser_routes.append(
|
||||
Mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
)
|
||||
logger.info(f"Mounted static files from {static_dir}")
|
||||
|
||||
# Mount browser app at /app (webapp and admin routes)
|
||||
routes.append(Mount("/app", app=browser_app))
|
||||
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
||||
browser_app = Starlette(routes=browser_routes)
|
||||
browser_app.add_middleware(
|
||||
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||
)
|
||||
|
||||
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||
routes.append(
|
||||
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
||||
)
|
||||
|
||||
# Mount browser app at /app (webapp and admin routes)
|
||||
routes.append(Mount("/app", app=browser_app))
|
||||
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
||||
else:
|
||||
logger.info("Admin UI (/app) disabled in Smithery stateless mode")
|
||||
|
||||
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
@@ -1689,4 +1880,11 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
|
||||
logger.info("WWW-Authenticate scope challenge handler enabled")
|
||||
|
||||
# ADR-016: Apply SmitheryConfigMiddleware in Smithery stateless mode
|
||||
# This must be the outermost middleware to extract config from URL query parameters
|
||||
# before any other middleware processes the request
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
app = SmitheryConfigMiddleware(app)
|
||||
logger.info("SmitheryConfigMiddleware enabled for query parameter config")
|
||||
|
||||
return app
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
"""Helper functions for accessing context in MCP tools."""
|
||||
|
||||
import logging
|
||||
|
||||
from httpx import BasicAuth
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
get_deployment_mode,
|
||||
get_settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
ADR-005 compliant implementation supporting two modes:
|
||||
1. BasicAuth mode: Returns shared client from lifespan context
|
||||
2. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Token already contains both MCP and Nextcloud audiences - use directly
|
||||
3. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchange MCP token for Nextcloud token via RFC 8693
|
||||
ADR-016 compliant implementation supporting three deployment modes:
|
||||
|
||||
1. Smithery stateless mode (SMITHERY_DEPLOYMENT=true):
|
||||
Create client from session configuration (nextcloud_url, username, app_password)
|
||||
No persistent state - client created per-request from Smithery session config.
|
||||
|
||||
2. BasicAuth mode: Returns shared client from lifespan context
|
||||
|
||||
3. OAuth mode:
|
||||
a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Token already contains both MCP and Nextcloud audiences - use directly
|
||||
b. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchange MCP token for Nextcloud token via RFC 8693
|
||||
|
||||
SECURITY: Token passthrough has been REMOVED. All OAuth modes validate
|
||||
proper token audiences per MCP Security Best Practices specification.
|
||||
@@ -24,7 +40,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
by the MCP server via @require_scopes decorator, not by the IdP.
|
||||
|
||||
This function automatically detects the authentication mode by checking
|
||||
the type of the lifespan context.
|
||||
the deployment mode and type of the lifespan context.
|
||||
|
||||
Args:
|
||||
ctx: MCP request context
|
||||
@@ -34,6 +50,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
|
||||
Raises:
|
||||
AttributeError: If context doesn't contain expected data
|
||||
ValueError: If Smithery mode but session config is missing required fields
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -43,6 +60,12 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
return await client.capabilities()
|
||||
```
|
||||
"""
|
||||
deployment_mode = get_deployment_mode()
|
||||
|
||||
# ADR-016: Smithery stateless mode - create client from session config
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
return _get_client_from_session_config(ctx)
|
||||
|
||||
settings = get_settings()
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
@@ -75,3 +98,82 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
|
||||
f"Type: {type(lifespan_ctx)}"
|
||||
)
|
||||
|
||||
|
||||
def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Create NextcloudClient from Smithery session configuration.
|
||||
|
||||
ADR-016: In Smithery stateless mode, each request includes session config
|
||||
with the user's Nextcloud credentials. This function creates a fresh client
|
||||
for each request - no state is persisted between requests.
|
||||
|
||||
For container runtime, config is extracted from URL query parameters by
|
||||
SmitheryConfigMiddleware and stored in a context variable.
|
||||
|
||||
Expected session config fields (from Smithery configSchema):
|
||||
- nextcloud_url: str - Nextcloud instance URL (required)
|
||||
- username: str - Nextcloud username (required)
|
||||
- app_password: str - Nextcloud app password (required)
|
||||
|
||||
Args:
|
||||
ctx: MCP request context (not used directly for Smithery config)
|
||||
|
||||
Returns:
|
||||
NextcloudClient configured with session credentials
|
||||
|
||||
Raises:
|
||||
ValueError: If required session config fields are missing
|
||||
"""
|
||||
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
||||
from nextcloud_mcp_server.app import get_smithery_session_config
|
||||
|
||||
session_config = get_smithery_session_config()
|
||||
|
||||
if session_config is None:
|
||||
raise ValueError(
|
||||
"Session configuration required in Smithery mode. "
|
||||
"Ensure nextcloud_url, username, and app_password are provided as URL query parameters."
|
||||
)
|
||||
|
||||
# Extract required fields - config is always a dict from SmitheryConfigMiddleware
|
||||
nextcloud_url = session_config.get("nextcloud_url")
|
||||
username = session_config.get("username")
|
||||
app_password = session_config.get("app_password")
|
||||
|
||||
# Validate required fields
|
||||
missing_fields = []
|
||||
if not nextcloud_url:
|
||||
missing_fields.append("nextcloud_url")
|
||||
if not username:
|
||||
missing_fields.append("username")
|
||||
if not app_password:
|
||||
missing_fields.append("app_password")
|
||||
|
||||
if missing_fields:
|
||||
raise ValueError(
|
||||
f"Missing required session config fields: {', '.join(missing_fields)}. "
|
||||
f"Configure these in the Smithery connection settings."
|
||||
)
|
||||
|
||||
# Type assertions after validation (for type checker)
|
||||
# These are guaranteed to be str after the missing_fields check above
|
||||
assert nextcloud_url is not None
|
||||
assert username is not None
|
||||
assert app_password is not None
|
||||
|
||||
# Validate URL format
|
||||
if not nextcloud_url.startswith(("http://", "https://")):
|
||||
raise ValueError(
|
||||
f"Invalid nextcloud_url: {nextcloud_url}. "
|
||||
f"Must start with http:// or https://"
|
||||
)
|
||||
|
||||
logger.debug(f"Creating Smithery client for {nextcloud_url} as {username}")
|
||||
|
||||
# Create client with session credentials using BasicAuth
|
||||
return NextcloudClient(
|
||||
base_url=nextcloud_url,
|
||||
username=username,
|
||||
auth=BasicAuth(username, app_password),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user