fix(smithery): Use container runtime pattern for config discovery

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 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-22 18:22:55 +01:00
parent b8dc413b73
commit 706a15f0bc
6 changed files with 142 additions and 101 deletions
+1 -2
View File
@@ -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"]
+1
View File
@@ -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.**
+126
View File
@@ -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
+13 -14
View File
@@ -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 = []
-82
View File
@@ -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
+1 -3
View File
@@ -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"