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:
+1
-2
@@ -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"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# Nextcloud MCP Server
|
||||
|
||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
||||
[](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
|
||||
|
||||
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user