refactor: consolidate database storage for webhooks and OAuth tokens
Refactored the storage system to use a unified SQLite database for both webhook tracking and OAuth token storage, available in both BasicAuth and OAuth modes. Changes: - Renamed refresh_token_storage.py → storage.py - Made TOKEN_ENCRYPTION_KEY optional (only required for OAuth token ops) - Added registered_webhooks table with schema versioning - Added webhook storage methods (store, get, delete, list, clear) - Initialize storage in both BasicAuth and OAuth modes - Updated webhook routes to persist registrations in database - Database-first pattern for webhook status checks (performance) - Updated all imports across codebase Storage Behavior: - Database created automatically at startup if needed - Existing databases detected and reused - Server fails fast if database initialization fails - No migrations needed (OAuth feature is experimental) Testing: - Added 13 comprehensive unit tests for webhook storage - All 118 unit tests pass - All 5 smoke tests pass - Verified fail-fast behavior on initialization errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+46
-253
@@ -8,13 +8,12 @@ from typing import TYPE_CHECKING, Optional
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
|
||||
import anyio
|
||||
import click
|
||||
import httpx
|
||||
import uvicorn
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
@@ -42,7 +41,6 @@ from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||
from nextcloud_mcp_server.document_processors import get_registry
|
||||
from nextcloud_mcp_server.observability import (
|
||||
ObservabilityMiddleware,
|
||||
get_uvicorn_logging_config,
|
||||
setup_metrics,
|
||||
setup_tracing,
|
||||
)
|
||||
@@ -219,6 +217,7 @@ class AppContext:
|
||||
"""Application context for BasicAuth mode."""
|
||||
|
||||
client: NextcloudClient
|
||||
storage: Optional["RefreshTokenStorage"] = None
|
||||
document_send_stream: Optional[MemoryObjectSendStream] = None
|
||||
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
||||
shutdown_event: Optional[anyio.Event] = None
|
||||
@@ -292,7 +291,7 @@ async def load_oauth_client_credentials(
|
||||
|
||||
# Try loading from SQLite storage
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
@@ -346,7 +345,7 @@ async def load_oauth_client_credentials(
|
||||
|
||||
# Ensure OAuth client in SQLite storage
|
||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
@@ -396,6 +395,13 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
client = NextcloudClient.from_env()
|
||||
logger.info("Client initialization complete")
|
||||
|
||||
# Initialize persistent storage (for webhook tracking and future features)
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
logger.info("Persistent storage initialized (webhook tracking enabled)")
|
||||
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
@@ -450,6 +456,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
try:
|
||||
yield AppContext(
|
||||
client=client,
|
||||
storage=storage,
|
||||
document_send_stream=send_stream,
|
||||
document_receive_stream=receive_stream,
|
||||
shutdown_event=shutdown_event,
|
||||
@@ -466,7 +473,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
else:
|
||||
# No vector sync - simple lifecycle
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
yield AppContext(client=client, storage=storage)
|
||||
finally:
|
||||
logger.info("Shutting down BasicAuth mode")
|
||||
await client.close()
|
||||
@@ -583,7 +590,7 @@ async def setup_oauth_config():
|
||||
refresh_token_storage = None
|
||||
if enable_offline_access:
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import (
|
||||
from nextcloud_mcp_server.auth.storage import (
|
||||
RefreshTokenStorage,
|
||||
)
|
||||
|
||||
@@ -1041,6 +1048,23 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
logger.info(
|
||||
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
||||
)
|
||||
else:
|
||||
# BasicAuth mode - share storage with browser_app for webhook management
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
app.state.storage = storage
|
||||
|
||||
# Also share with browser_app for webhook routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/user":
|
||||
route.app.state.storage = storage
|
||||
logger.info(
|
||||
"Storage shared with browser_app for webhook management"
|
||||
)
|
||||
break
|
||||
|
||||
# Start background vector sync tasks for BasicAuth mode (ADR-007)
|
||||
# For streamable-http transport, FastMCP lifespan isn't automatically triggered
|
||||
@@ -1388,6 +1412,11 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
user_info_html,
|
||||
user_info_json,
|
||||
)
|
||||
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
|
||||
@@ -1397,6 +1426,16 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
Route(
|
||||
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
|
||||
), # /user/revoke → revoke_session
|
||||
# Webhook management routes (admin-only)
|
||||
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /user/webhooks
|
||||
Route(
|
||||
"/webhooks/enable/{preset_id:str}", enable_webhook_preset, methods=["POST"]
|
||||
),
|
||||
Route(
|
||||
"/webhooks/disable/{preset_id:str}",
|
||||
disable_webhook_preset,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
browser_app = Starlette(routes=browser_routes)
|
||||
@@ -1528,249 +1567,3 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
logger.info("WWW-Authenticate scope challenge handler enabled")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--host", "-h", default="127.0.0.1", show_default=True, help="Server host"
|
||||
)
|
||||
@click.option(
|
||||
"--port", "-p", type=int, default=8000, show_default=True, help="Server port"
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"-l",
|
||||
default="info",
|
||||
show_default=True,
|
||||
type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
|
||||
help="Logging level",
|
||||
)
|
||||
@click.option(
|
||||
"--transport",
|
||||
"-t",
|
||||
default="sse",
|
||||
show_default=True,
|
||||
type=click.Choice(["sse", "streamable-http", "http"]),
|
||||
help="MCP transport protocol",
|
||||
)
|
||||
@click.option(
|
||||
"--enable-app",
|
||||
"-e",
|
||||
multiple=True,
|
||||
type=click.Choice(
|
||||
["notes", "tables", "webdav", "calendar", "contacts", "cookbook", "deck"]
|
||||
),
|
||||
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth/--no-oauth",
|
||||
default=None,
|
||||
help="Force OAuth mode (if enabled) or BasicAuth mode (if disabled). By default, auto-detected based on environment variables.",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-client-id",
|
||||
envvar="NEXTCLOUD_OIDC_CLIENT_ID",
|
||||
help="OAuth client ID (can also use NEXTCLOUD_OIDC_CLIENT_ID env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-client-secret",
|
||||
envvar="NEXTCLOUD_OIDC_CLIENT_SECRET",
|
||||
help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--mcp-server-url",
|
||||
envvar="NEXTCLOUD_MCP_SERVER_URL",
|
||||
default="http://localhost:8000",
|
||||
show_default=True,
|
||||
help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--nextcloud-host",
|
||||
envvar="NEXTCLOUD_HOST",
|
||||
help="Nextcloud instance URL (can also use NEXTCLOUD_HOST env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--nextcloud-username",
|
||||
envvar="NEXTCLOUD_USERNAME",
|
||||
help="Nextcloud username for BasicAuth (can also use NEXTCLOUD_USERNAME env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--nextcloud-password",
|
||||
envvar="NEXTCLOUD_PASSWORD",
|
||||
help="Nextcloud password for BasicAuth (can also use NEXTCLOUD_PASSWORD env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-scopes",
|
||||
envvar="NEXTCLOUD_OIDC_SCOPES",
|
||||
default="openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
|
||||
show_default=True,
|
||||
help="OAuth scopes to request during client registration. These define the maximum allowed scopes for the client. Note: Actual supported scopes are discovered dynamically from MCP tools at runtime. (can also use NEXTCLOUD_OIDC_SCOPES env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-token-type",
|
||||
envvar="NEXTCLOUD_OIDC_TOKEN_TYPE",
|
||||
default="bearer",
|
||||
show_default=True,
|
||||
type=click.Choice(["bearer", "jwt"], case_sensitive=False),
|
||||
help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--public-issuer-url",
|
||||
envvar="NEXTCLOUD_PUBLIC_ISSUER_URL",
|
||||
help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)",
|
||||
)
|
||||
def run(
|
||||
host: str,
|
||||
port: int,
|
||||
log_level: str,
|
||||
transport: str,
|
||||
enable_app: tuple[str, ...],
|
||||
oauth: bool | None,
|
||||
oauth_client_id: str | None,
|
||||
oauth_client_secret: str | None,
|
||||
mcp_server_url: str,
|
||||
nextcloud_host: str | None,
|
||||
nextcloud_username: str | None,
|
||||
nextcloud_password: str | None,
|
||||
oauth_scopes: str,
|
||||
oauth_token_type: str,
|
||||
public_issuer_url: str | None,
|
||||
):
|
||||
"""
|
||||
Run the Nextcloud MCP server.
|
||||
|
||||
\b
|
||||
Authentication Modes:
|
||||
- BasicAuth: Set NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD
|
||||
- OAuth: Leave USERNAME/PASSWORD unset (requires OIDC app enabled)
|
||||
|
||||
\b
|
||||
Examples:
|
||||
# BasicAuth mode with CLI options
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com \\
|
||||
--nextcloud-username=admin --nextcloud-password=secret
|
||||
|
||||
# BasicAuth mode with env vars (recommended for credentials)
|
||||
$ export NEXTCLOUD_HOST=https://cloud.example.com
|
||||
$ export NEXTCLOUD_USERNAME=admin
|
||||
$ export NEXTCLOUD_PASSWORD=secret
|
||||
$ nextcloud-mcp-server --host 0.0.0.0 --port 8000
|
||||
|
||||
# OAuth mode with auto-registration
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth
|
||||
|
||||
# OAuth mode with pre-configured client
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||
--oauth-client-id=xxx --oauth-client-secret=yyy
|
||||
|
||||
# OAuth mode with custom scopes and JWT tokens
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||
--oauth-scopes="openid notes:read notes:write" --oauth-token-type=jwt
|
||||
|
||||
# OAuth with public issuer URL (for Docker/proxy setups)
|
||||
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
|
||||
--public-issuer-url=http://localhost:8080
|
||||
"""
|
||||
# Set env vars from CLI options if provided
|
||||
if nextcloud_host:
|
||||
os.environ["NEXTCLOUD_HOST"] = nextcloud_host
|
||||
if nextcloud_username:
|
||||
os.environ["NEXTCLOUD_USERNAME"] = nextcloud_username
|
||||
if nextcloud_password:
|
||||
os.environ["NEXTCLOUD_PASSWORD"] = nextcloud_password
|
||||
if oauth_client_id:
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
|
||||
if oauth_client_secret:
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
|
||||
if oauth_scopes:
|
||||
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
|
||||
if oauth_token_type:
|
||||
os.environ["NEXTCLOUD_OIDC_TOKEN_TYPE"] = oauth_token_type
|
||||
if mcp_server_url:
|
||||
os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
|
||||
if public_issuer_url:
|
||||
os.environ["NEXTCLOUD_PUBLIC_ISSUER_URL"] = public_issuer_url
|
||||
|
||||
# Force OAuth mode if explicitly requested
|
||||
if oauth is True:
|
||||
# Clear username/password to force OAuth mode
|
||||
if "NEXTCLOUD_USERNAME" in os.environ:
|
||||
click.echo(
|
||||
"Warning: --oauth flag set, ignoring NEXTCLOUD_USERNAME", err=True
|
||||
)
|
||||
del os.environ["NEXTCLOUD_USERNAME"]
|
||||
if "NEXTCLOUD_PASSWORD" in os.environ:
|
||||
click.echo(
|
||||
"Warning: --oauth flag set, ignoring NEXTCLOUD_PASSWORD", err=True
|
||||
)
|
||||
del os.environ["NEXTCLOUD_PASSWORD"]
|
||||
|
||||
# Validate OAuth configuration
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
raise click.ClickException(
|
||||
"OAuth mode requires NEXTCLOUD_HOST environment variable to be set"
|
||||
)
|
||||
|
||||
# Check if we have client credentials OR if dynamic registration is possible
|
||||
has_client_creds = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") and os.getenv(
|
||||
"NEXTCLOUD_OIDC_CLIENT_SECRET"
|
||||
)
|
||||
|
||||
if not has_client_creds:
|
||||
# No client credentials - will attempt dynamic registration
|
||||
# Show helpful message before server starts
|
||||
click.echo("", err=True)
|
||||
click.echo("OAuth Configuration:", err=True)
|
||||
click.echo(" Mode: Dynamic Client Registration", err=True)
|
||||
click.echo(" Host: " + nextcloud_host, err=True)
|
||||
click.echo(" Storage: SQLite (TOKEN_STORAGE_DB)", err=True)
|
||||
click.echo("", err=True)
|
||||
click.echo(
|
||||
"Note: Make sure 'Dynamic Client Registration' is enabled", err=True
|
||||
)
|
||||
click.echo(" in your Nextcloud OIDC app settings.", err=True)
|
||||
click.echo("", err=True)
|
||||
else:
|
||||
click.echo("", err=True)
|
||||
click.echo("OAuth Configuration:", err=True)
|
||||
click.echo(" Mode: Pre-configured Client", err=True)
|
||||
click.echo(" Host: " + nextcloud_host, err=True)
|
||||
click.echo(
|
||||
" Client ID: "
|
||||
+ os.getenv("NEXTCLOUD_OIDC_CLIENT_ID", "")[:16]
|
||||
+ "...",
|
||||
err=True,
|
||||
)
|
||||
click.echo("", err=True)
|
||||
|
||||
elif oauth is False:
|
||||
# Force BasicAuth mode - verify credentials exist
|
||||
if not os.getenv("NEXTCLOUD_USERNAME") or not os.getenv("NEXTCLOUD_PASSWORD"):
|
||||
raise click.ClickException(
|
||||
"--no-oauth flag set but NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD not set"
|
||||
)
|
||||
|
||||
enabled_apps = list(enable_app) if enable_app else None
|
||||
|
||||
app = get_app(transport=transport, enabled_apps=enabled_apps)
|
||||
|
||||
# Get observability settings and create uvicorn logging config
|
||||
settings = get_settings()
|
||||
uvicorn_log_config = get_uvicorn_logging_config(
|
||||
log_format=settings.log_format,
|
||||
log_level=settings.log_level,
|
||||
include_trace_context=settings.log_include_trace_context,
|
||||
)
|
||||
|
||||
uvicorn.run(
|
||||
app=app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level=log_level,
|
||||
log_config=uvicorn_log_config,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
Reference in New Issue
Block a user