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 \
|
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()"
|
CMD uv run python -c "import httpx; httpx.get('http://localhost:${PORT:-8081}/health/live').raise_for_status()"
|
||||||
|
|
||||||
# Run the Smithery-specific entrypoint
|
CMD ["/app/.venv/bin/smithery-main"]
|
||||||
CMD ["uv", "run", "python", "-m", "nextcloud_mcp_server.smithery_main"]
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
# Nextcloud MCP Server
|
# Nextcloud MCP Server
|
||||||
|
|
||||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/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.**
|
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ from starlette.middleware.cors import CORSMiddleware
|
|||||||
from starlette.responses import JSONResponse, RedirectResponse
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
from starlette.routing import Mount, Route
|
from starlette.routing import Mount, Route
|
||||||
from starlette.staticfiles import StaticFiles
|
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 (
|
from nextcloud_mcp_server.auth import (
|
||||||
InsufficientScopeError,
|
InsufficientScopeError,
|
||||||
@@ -276,6 +279,102 @@ class SmitheryAppContext:
|
|||||||
pass # No shared state needed - everything comes 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
|
||||||
|
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
|
@asynccontextmanager
|
||||||
async def app_lifespan_smithery(server: FastMCP) -> AsyncIterator[SmitheryAppContext]:
|
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")
|
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.
|
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
|
||||||
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
|
# 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")
|
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
|
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
|
with the user's Nextcloud credentials. This function creates a fresh client
|
||||||
for each request - no state is persisted between requests.
|
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):
|
Expected session config fields (from Smithery configSchema):
|
||||||
- nextcloud_url: str - Nextcloud instance URL (required)
|
- nextcloud_url: str - Nextcloud instance URL (required)
|
||||||
- username: str - Nextcloud username (required)
|
- username: str - Nextcloud username (required)
|
||||||
- app_password: str - Nextcloud app password (required)
|
- app_password: str - Nextcloud app password (required)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: MCP request context containing session_config
|
ctx: MCP request context (not used directly for Smithery config)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
NextcloudClient configured with session credentials
|
NextcloudClient configured with session credentials
|
||||||
@@ -122,25 +125,21 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If required session config fields are missing
|
ValueError: If required session config fields are missing
|
||||||
"""
|
"""
|
||||||
# Access session config from context
|
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
||||||
# In Smithery mode, this is populated from URL parameters
|
from nextcloud_mcp_server.app import get_smithery_session_config
|
||||||
session_config = getattr(ctx, "session_config", None)
|
|
||||||
|
session_config = get_smithery_session_config()
|
||||||
|
|
||||||
if session_config is None:
|
if session_config is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Session configuration required in Smithery mode. "
|
"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
|
# Extract required fields - config is always a dict from SmitheryConfigMiddleware
|
||||||
if isinstance(session_config, dict):
|
nextcloud_url = session_config.get("nextcloud_url")
|
||||||
nextcloud_url = session_config.get("nextcloud_url")
|
username = session_config.get("username")
|
||||||
username = session_config.get("username")
|
app_password = session_config.get("app_password")
|
||||||
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
|
# Validate required fields
|
||||||
missing_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]
|
[project.scripts]
|
||||||
nextcloud-mcp-server = "nextcloud_mcp_server.cli:run"
|
nextcloud-mcp-server = "nextcloud_mcp_server.cli:run"
|
||||||
|
smithery-main = "nextcloud_mcp_server.smithery_main:main"
|
||||||
|
|
||||||
[[tool.uv.index]]
|
[[tool.uv.index]]
|
||||||
name = "testpypi"
|
name = "testpypi"
|
||||||
url = "https://test.pypi.org/simple/"
|
url = "https://test.pypi.org/simple/"
|
||||||
publish-url = "https://test.pypi.org/legacy/"
|
publish-url = "https://test.pypi.org/legacy/"
|
||||||
explicit = true
|
explicit = true
|
||||||
|
|
||||||
[tool.smithery]
|
|
||||||
server = "nextcloud_mcp_server.smithery_server:create_server"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user