From 706a15f0bc60f9e38b763c77ffedd0b721de0b4f Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 22 Nov 2025 18:22:55 +0100 Subject: [PATCH] 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"