Compare commits

..

1 Commits

Author SHA1 Message Date
renovate-bot-cbcoutinho[bot] ac116366e9 chore(deps): update dependency python to 3.14 2025-12-20 11:13:15 +00:00
74 changed files with 899 additions and 7527 deletions
+6 -6
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v4
- name: Get version from tag
id: tag
@@ -35,18 +35,18 @@ jobs:
echo "Version validated: $INFO_VERSION"
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup PHP
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
uses: shivammathur/setup-php@v2
with:
php-version: 8.1
coverage: none
- name: Checkout Nextcloud server (for signing)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v4
with:
repository: nextcloud/server
ref: stable30
@@ -70,7 +70,7 @@ jobs:
run: make appstore server_dir=${{ github.workspace }}/server
- name: Create GitHub release and attach tarball
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ env.APP_DIR }}/build/artifacts/${{ env.APP_NAME }}.tar.gz
@@ -80,7 +80,7 @@ jobs:
prerelease: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
- name: Upload to Nextcloud App Store
uses: R0Wi/nextcloud-appstore-push-action@9244bb5445776688cfe90fa1903ea8dff95b0c28 # v1.0.4
uses: R0Wi/nextcloud-appstore-push-action@v1.0.4
with:
app_name: ${{ env.APP_NAME }}
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
+25 -32
View File
@@ -21,9 +21,9 @@ jobs:
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.14'
- name: Install uv
run: |
@@ -130,36 +130,29 @@ jobs:
echo "Pushed tags for components:${{ steps.bump.outputs.components }}"
- name: Summary
if: steps.bump.outputs.bumped == 'true'
run: |
if [ "${{ steps.bump.outputs.bumped }}" == "true" ]; then
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The following components were bumped:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The following components were bumped:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
for component in ${{ steps.bump.outputs.components }}; do
case $component in
mcp)
tag=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | head -n 1)
echo "- **MCP Server**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
helm)
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
astrolabe)
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
esac
done
for component in ${{ steps.bump.outputs.components }}; do
case $component in
mcp)
tag=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | head -n 1)
echo "- **MCP Server**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
helm)
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
astrolabe)
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
esac
done
echo "" >> $GITHUB_STEP_SUMMARY
echo "Tags have been pushed and release workflows will trigger automatically." >> $GITHUB_STEP_SUMMARY
else
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ No version bumps required - no relevant commits found since last release." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The workflow completed successfully with no changes." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "Tags have been pushed and release workflows will trigger automatically." >> $GITHUB_STEP_SUMMARY
-17
View File
@@ -48,23 +48,6 @@ jobs:
###### Required to build OIDC App ######
###### Required to build Astrolabe App ######
- name: Set up Node.js for Astrolabe
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '20'
- name: Build Astrolabe app
run: |
cd third_party/astrolabe
composer install --no-dev --optimize-autoloader
npm ci
npm run build
###### Required to build Astrolabe App ######
- name: Run docker compose
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
with:
-34
View File
@@ -5,40 +5,6 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
## v0.59.0 (2025-12-22)
### Feat
- **helm**: add support for multi-user BasicAuth mode
### Fix
- **helm**: address PR #447 reviewer feedback
- **helm**: include MCP server version bumps in changelog pattern
## v0.58.0 (2025-12-22)
### Feat
- **config**: enable DCR for multi-user BasicAuth with offline access
- **astrolabe**: implement app password provisioning for multi-user background sync
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
## v0.57.0 (2025-12-20)
### Feat
- **auth**: add multi-user BasicAuth pass-through mode
- **astrolabe**: add dynamic MCP server configuration for testing
### Fix
- **config**: address reviewer feedback
### Refactor
- **config**: centralize configuration validation and simplify startup
## v0.56.2 (2025-12-20)
### Fix
+1 -1
View File
@@ -127,7 +127,7 @@ This enables natural language queries and helps discover related content across
> [!NOTE]
> **Semantic Search is experimental and opt-in:**
> - Disabled by default (`ENABLE_SEMANTIC_SEARCH=false`)
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
> - Currently supports Notes app only (multi-app support planned)
> - Requires additional infrastructure: vector database + embedding service
> - Answer generation (`nc_semantic_search_answer`) requires MCP client sampling support
@@ -2,7 +2,7 @@
set -euox pipefail
echo "Installing Astrolabe app for testing..."
echo "Installing and configuring Astrolabe app for testing..."
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
if [ -d /opt/apps/astrolabe ]; then
@@ -30,7 +30,55 @@ else
php /var/www/html/occ app:enable astrolabe
fi
echo "✓ Astrolabe app installed successfully"
echo ""
echo "Note: MCP server configuration is managed dynamically during tests"
echo " to support testing multiple MCP server deployments."
# Configure MCP server URLs in Nextcloud system config
# - mcp_server_url: Internal URL for PHP app to call MCP server APIs (Docker internal network)
# - mcp_server_public_url: Public URL for OAuth token audience (what browsers/MCP clients see)
php /var/www/html/occ config:system:set mcp_server_url --value='http://mcp-oauth:8001'
php /var/www/html/occ config:system:set mcp_server_public_url --value='http://localhost:8001'
# Create OAuth client for Astrolabe app
# The resource_url MUST match what the MCP server expects as token audience
# This allows tokens from this client to be validated by MCP server's UnifiedTokenVerifier
MCP_CLIENT_ID="nextcloudMcpServerUIPublicClient"
MCP_RESOURCE_URL="http://localhost:8001"
MCP_REDIRECT_URI="http://localhost:8080/apps/astrolabe/oauth/callback"
echo "Configuring OAuth client for Astrolabe..."
# Check if client already exists
if php /var/www/html/occ oidc:list 2>/dev/null | grep -q "$MCP_CLIENT_ID"; then
echo "OAuth client $MCP_CLIENT_ID already exists, removing to recreate with correct settings..."
php /var/www/html/occ oidc:remove "$MCP_CLIENT_ID" || true
fi
# Create OAuth client with correct resource_url for MCP server audience
echo "Creating OAuth confidential client with resource_url=$MCP_RESOURCE_URL"
CLIENT_OUTPUT=$(php /var/www/html/occ oidc:create \
"Astrolabe" \
"$MCP_REDIRECT_URI" \
--client_id="$MCP_CLIENT_ID" \
--type=confidential \
--flow=code \
--token_type=jwt \
--resource_url="$MCP_RESOURCE_URL" \
--allowed_scopes="openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write")
echo "$CLIENT_OUTPUT"
# Extract client_secret from JSON output
CLIENT_SECRET=$(echo "$CLIENT_OUTPUT" | php -r 'echo json_decode(file_get_contents("php://stdin"), true)["client_secret"] ?? "";')
if [ -n "$CLIENT_SECRET" ]; then
echo "Configuring Astrolabe client secret in system config..."
php /var/www/html/occ config:system:set astrolabe_client_secret --value="$CLIENT_SECRET"
echo "✓ Client secret configured: ${CLIENT_SECRET:0:8}..."
else
echo "⚠ Warning: Could not extract client_secret from OIDC client creation"
fi
# Configure OAuth client ID in system config
echo "Configuring Astrolabe client ID in system config..."
php /var/www/html/occ config:system:set astrolabe_client_id --value="$MCP_CLIENT_ID"
echo "✓ Client ID configured: $MCP_CLIENT_ID"
echo "Astrolabe app installed and configured successfully"
+1 -2
View File
@@ -18,8 +18,7 @@ ignored_tag_formats = [
]
# Filter commits by scope
# Includes helm-scoped commits AND MCP server version bumps (which update appVersion)
[tool.commitizen.customize]
changelog_pattern = "^((feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:|bump: version.*→.*)"
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:"
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:\\s.+"
message_template = "{{change_type}}(helm): {{message}}"
+3 -3
View File
@@ -1,9 +1,9 @@
dependencies:
- name: qdrant
repository: https://qdrant.github.io/qdrant-helm
version: 1.16.3
version: 1.16.2
- name: ollama
repository: https://otwld.github.io/ollama-helm
version: 1.36.0
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
generated: "2025-12-22T11:09:39.166328543Z"
digest: sha256:fab8008217b27ff4e3e139c2f481eedaa23f9f64a3a086d0e9deea2195b69b63
generated: "2025-12-14T11:07:07.024787592Z"
+2 -2
View File
@@ -3,7 +3,7 @@ name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application
version: 0.54.0
appVersion: "0.59.0"
appVersion: "0.56.2"
keywords:
- nextcloud
- mcp
@@ -27,7 +27,7 @@ annotations:
grafana_dashboard_folder: "Nextcloud MCP"
dependencies:
- name: qdrant
version: "1.16.3"
version: "1.16.2"
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
@@ -72,28 +72,6 @@ Create the name of the secret to use for basic auth
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use for multi-user basic auth
*/}}
{{- define "nextcloud-mcp-server.multiUserBasicSecretName" -}}
{{- if .Values.auth.multiUserBasic.existingSecret }}
{{- .Values.auth.multiUserBasic.existingSecret }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-multi-user-basic
{{- end }}
{{- end }}
{{/*
Create the name of the PVC to use for multi-user basic token storage
*/}}
{{- define "nextcloud-mcp-server.multiUserBasicPvcName" -}}
{{- if .Values.auth.multiUserBasic.persistence.existingClaim }}
{{- .Values.auth.multiUserBasic.persistence.existingClaim }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-token-storage
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use for OAuth
*/}}
@@ -68,7 +68,7 @@ spec:
- name: NEXTCLOUD_HOST
value: {{ .Values.nextcloud.host | quote }}
{{- if eq .Values.auth.mode "basic" }}
# Basic auth mode (single-user)
# Basic auth mode
- name: NEXTCLOUD_USERNAME
valueFrom:
secretKeyRef:
@@ -79,41 +79,6 @@ spec:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
key: {{ .Values.auth.basic.passwordKey }}
{{- else if eq .Values.auth.mode "multi-user-basic" }}
# Multi-user BasicAuth mode (pass-through)
- name: ENABLE_MULTI_USER_BASIC_AUTH
value: "true"
- name: NEXTCLOUD_MCP_SERVER_URL
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
# Background operations with app passwords
- name: ENABLE_OFFLINE_ACCESS
value: "true"
- name: TOKEN_STORAGE_DB
value: {{ .Values.auth.multiUserBasic.tokenStorageDb | quote }}
- name: TOKEN_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
key: {{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}
- name: NEXTCLOUD_OIDC_SCOPES
value: {{ .Values.auth.multiUserBasic.scopes | quote }}
{{- if .Values.auth.multiUserBasic.clientId }}
# Static OAuth credentials (optional - uses DCR if not provided)
- name: NEXTCLOUD_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
key: {{ .Values.auth.multiUserBasic.clientIdKey }}
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
key: {{ .Values.auth.multiUserBasic.clientSecretKey }}
{{- end }}
{{- end }}
{{- else if eq .Values.auth.mode "oauth" }}
# OAuth mode
- name: NEXTCLOUD_MCP_SERVER_URL
@@ -286,10 +251,6 @@ spec:
- name: oauth-storage
mountPath: /app/.oauth
{{- end }}
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
- name: token-storage
mountPath: /app/data
{{- end }}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
- name: qdrant-data
mountPath: /app/data
@@ -305,11 +266,6 @@ spec:
persistentVolumeClaim:
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
{{- end }}
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
- name: token-storage
persistentVolumeClaim:
claimName: {{ include "nextcloud-mcp-server.multiUserBasicPvcName" . }}
{{- end }}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
- name: qdrant-data
persistentVolumeClaim:
@@ -16,24 +16,6 @@ spec:
storage: {{ .Values.auth.oauth.persistence.size }}
{{- end }}
---
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.auth.multiUserBasic.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-token-storage
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.auth.multiUserBasic.persistence.accessMode }}
{{- if .Values.auth.multiUserBasic.persistence.storageClass }}
storageClassName: {{ .Values.auth.multiUserBasic.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.auth.multiUserBasic.persistence.size }}
{{- end }}
---
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
@@ -13,24 +13,6 @@ data:
{{- end }}
{{- end }}
---
{{- if eq .Values.auth.mode "multi-user-basic" }}
{{- if and .Values.auth.multiUserBasic.enableOfflineAccess (not .Values.auth.multiUserBasic.existingSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-multi-user-basic
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
type: Opaque
data:
{{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}: {{ .Values.auth.multiUserBasic.tokenEncryptionKey | b64enc | quote }}
{{- if .Values.auth.multiUserBasic.clientId }}
{{ .Values.auth.multiUserBasic.clientIdKey }}: {{ .Values.auth.multiUserBasic.clientId | b64enc | quote }}
{{ .Values.auth.multiUserBasic.clientSecretKey }}: {{ .Values.auth.multiUserBasic.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
{{- end }}
---
{{- if eq .Values.auth.mode "oauth" }}
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
apiVersion: v1
+4 -46
View File
@@ -33,15 +33,14 @@ nextcloud:
publicIssuerUrl: ""
# Authentication configuration
# Choose one mode: "basic", "multi-user-basic", or "oauth"
# Choose either basic auth OR oauth (not both)
auth:
# Authentication mode: "basic", "multi-user-basic", or "oauth"
# basic: Single-user with username/password (recommended for personal use)
# multi-user-basic: Multi-user with BasicAuth pass-through (credentials in request headers)
# Authentication mode: "basic" or "oauth"
# basic: Uses username/password (recommended for most users)
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
mode: basic
# Basic authentication settings (single-user mode)
# Basic authentication settings
basic:
# Nextcloud username (ignored if existingSecret is set)
username: ""
@@ -59,47 +58,6 @@ auth:
usernameKey: "username"
passwordKey: "password"
# Multi-user BasicAuth settings (pass-through mode)
# Users provide credentials in request headers (Authorization: Basic ...)
# Server optionally stores app passwords for background operations
multiUserBasic:
# Enable offline access (background operations using app passwords via Astrolabe)
# When enabled, requires token encryption key. OAuth client credentials are optional (uses DCR if not provided)
enableOfflineAccess: false
# Token encryption key (required if enableOfflineAccess: true, ignored if existingSecret is set)
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
tokenEncryptionKey: ""
# Token storage database path
tokenStorageDb: "/app/data/tokens.db"
# OAuth client credentials (optional - uses Dynamic Client Registration if not provided)
# Only needed if enableOfflineAccess: true
clientId: ""
clientSecret: ""
# OAuth scopes to request (space-separated)
scopes: "openid profile email offline_access notes:read notes:write calendar:read calendar: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 todo:read todo:write"
# Use existing secret for multi-user basic auth credentials
# If set, tokenEncryptionKey, clientId, and clientSecret above are ignored
# Secret should contain keys specified in the *Key fields below
# Example:
# kubectl create secret generic my-multiuser-creds \
# --from-literal=token_encryption_key=ESF1BvEQ... \
# --from-literal=client_id=my-client-id \
# --from-literal=client_secret=my-client-secret
existingSecret: ""
# Keys in the existing secret
tokenEncryptionKeyKey: "token_encryption_key"
clientIdKey: "client_id"
clientSecretKey: "client_secret"
# Persistent storage for token database
persistence:
enabled: true
# Storage class (leave empty for default)
storageClass: ""
accessMode: ReadWriteOnce
size: 100Mi
# Use existing PVC
existingClaim: ""
# OAuth2/OIDC settings (experimental)
oauth:
# OAuth token type: "jwt" or "opaque"
+2 -43
View File
@@ -35,7 +35,7 @@ services:
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
#- ./third_party:/opt/apps:ro
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
#- ./third_party/astrolabe:/opt/apps/astrolabe:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -87,7 +87,7 @@ services:
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Vector sync configuration (ADR-007)
#- VECTOR_SYNC_ENABLED=true
- VECTOR_SYNC_ENABLED=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -123,41 +123,6 @@ services:
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
mcp-multi-user-basic:
build: .
restart: always
command: ["--transport", "streamable-http"]
depends_on:
app:
condition: service_healthy
ports:
- 127.0.0.1:8003:8000
environment:
# Multi-user BasicAuth pass-through mode (ADR-020)
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- ENABLE_MULTI_USER_BASIC_AUTH=true
- ENABLE_OFFLINE_ACCESS=true
- ENABLE_BACKGROUND_OPERATIONS=true
# Token storage (required for middleware initialization)
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
- VECTOR_SYNC_ENABLED=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
# OAuth credentials for background sync (optional - uses DCR if not provided)
# Uncomment to avoid DCR:
# - NEXTCLOUD_OIDC_CLIENT_ID=your_client_id
# - NEXTCLOUD_OIDC_CLIENT_SECRET=your_client_secret
# NO admin credentials - credentials come from client Authorization header
volumes:
- multi-user-basic-data:/app/data
mcp-oauth:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
@@ -194,11 +159,6 @@ services:
# Qdrant configuration - persistent local storage
- QDRANT_LOCATION=/app/data/qdrant
# Embedding provider for vector sync (use Simple provider as fallback)
# Ollama not available in CI/test environments
# - OLLAMA_BASE_URL=http://ollama:11434
# - OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
# Client credentials registered via RFC 7591 and stored in volume
# JWT token type is used for testing (faster validation, scopes embedded in token)
@@ -320,4 +280,3 @@ volumes:
keycloak-oauth-storage:
qdrant-data:
mcp-data:
multi-user-basic-data:
@@ -1,342 +0,0 @@
# ADR-020: Deployment Modes and Configuration Validation
**Status:** Accepted
**Date:** 2025-12-20
**Deciders:** Development Team
**Related:** ADR-002 (Vector Sync), ADR-004 (Progressive Consent), ADR-019 (Multi-user BasicAuth)
## Context
The MCP server supports multiple deployment scenarios with different authentication methods, storage backends, and feature sets. Over time, the configuration system evolved to support ~500+ possible combinations across deployment modes, authentication patterns, and feature toggles. This complexity made it difficult to:
1. Understand what configuration is required for a given deployment
2. Debug configuration errors (validation scattered across multiple files)
3. Provide helpful error messages when configuration is invalid
4. Maintain clear boundaries between deployment modes
**Problems Identified:**
- No single source of truth for "what config is required for mode X"
- Validation happening at 4+ different points (Settings.__post_init__, setup_oauth_config(), context helpers, starlette_lifespan)
- Startup sequence unclear (OAuth setup before FastMCP creation, sync initialization errors)
- Error messages generic ("X is required") without explaining which deployment mode triggered the requirement
- Multiple overlapping decision trees (deployment mode, auth mode, features)
## Decision
We formalize five distinct deployment modes with explicit configuration requirements and implement centralized configuration validation.
### Deployment Modes
#### 1. Single-User BasicAuth
**Use Case:** Personal Nextcloud instance, local development
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password # Or app password
```
**Optional Configuration:**
```bash
# Vector sync (semantic search)
VECTOR_SYNC_ENABLED=true
QDRANT_LOCATION=/path/to/qdrant # Or QDRANT_URL for remote
# Embeddings (optional - Simple provider used as fallback)
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Document processing
DOCUMENT_CHUNK_SIZE=512
DOCUMENT_CHUNK_OVERLAP=50
```
**Characteristics:**
- Single shared NextcloudClient created at startup
- No OAuth infrastructure needed
- No multi-user support
- Vector sync runs as single-user background task
- Admin UI available at /app
---
#### 2. Multi-User BasicAuth Pass-Through
**Use Case:** Internal deployment where users provide their own credentials, no background sync needed
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
```
**Optional Configuration:**
```bash
# For background sync (requires app passwords from Astrolabe)
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
VECTOR_SYNC_ENABLED=true
# ... plus Qdrant and embedding config
```
**Conditional Requirements:**
- If `ENABLE_OFFLINE_ACCESS=true`: requires `NEXTCLOUD_OIDC_CLIENT_ID`, `NEXTCLOUD_OIDC_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB`
- If `VECTOR_SYNC_ENABLED=true`: requires `ENABLE_OFFLINE_ACCESS=true`
**Characteristics:**
- No OAuth for client authentication (uses BasicAuth in request headers)
- BasicAuthMiddleware extracts credentials from Authorization header
- Client created per-request from extracted credentials
- Optional: Background sync using app passwords (via Astrolabe API)
- Admin UI available at /app
---
#### 3. OAuth Single-Audience (Default)
**Use Case:** Multi-user deployment with OAuth authentication, tokens work for both MCP and Nextcloud
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
# No NEXTCLOUD_USERNAME/PASSWORD (triggers OAuth mode)
```
**Auto-Configured:**
- OIDC discovery URL: `{NEXTCLOUD_HOST}/.well-known/openid-configuration`
- Client credentials: Dynamic Client Registration (DCR) if available
- Token storage: SQLite at `~/.oauth/clients.db`
**Optional Configuration:**
```bash
# Static client credentials (instead of DCR)
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
# Offline access for background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
VECTOR_SYNC_ENABLED=true
# ... plus Qdrant and embedding config
# Scopes
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write ..."
```
**Conditional Requirements:**
- If `ENABLE_OFFLINE_ACCESS=true`: requires `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB`
- If `VECTOR_SYNC_ENABLED=true`: requires `ENABLE_OFFLINE_ACCESS=true`
**Characteristics:**
- Tokens contain both `aud: ["mcp-server", "nextcloud"]`
- Pass token through to Nextcloud APIs (no exchange)
- Client created per-request from token in Authorization header
- Background sync uses refresh tokens (if offline_access enabled)
- Admin UI available at /app
---
#### 4. OAuth Token Exchange (RFC 8693)
**Use Case:** Multi-user deployment where MCP token is separate from Nextcloud token
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# No NEXTCLOUD_USERNAME/PASSWORD (triggers OAuth mode)
```
**Optional Configuration:**
- Same as OAuth Single-Audience, plus:
```bash
TOKEN_EXCHANGE_CACHE_TTL=300 # Cache exchanged tokens
```
**Characteristics:**
- Tokens contain only `aud: "mcp-server"`
- MCP server exchanges token for Nextcloud token via RFC 8693
- Exchanged tokens cached per-user
- Client created per-request using exchanged token
- Background sync uses refresh tokens (if offline_access enabled)
---
#### 5. Smithery Stateless
**Use Case:** Multi-tenant SaaS deployment via Smithery platform
**Required Configuration:**
- None! Configuration comes from session URL params: `?nextcloud_url=...&username=...&app_password=...`
**Forbidden Configuration:**
- Must NOT set: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`, `ENABLE_MULTI_USER_BASIC_AUTH`, `ENABLE_TOKEN_EXCHANGE`, `ENABLE_OFFLINE_ACCESS`, `VECTOR_SYNC_ENABLED`, `NEXTCLOUD_OIDC_CLIENT_ID`, `NEXTCLOUD_OIDC_CLIENT_SECRET`
**Characteristics:**
- No persistent storage (stateless)
- Client created per-request from session config
- No vector sync (disabled)
- No admin UI (no /app routes)
- No OAuth infrastructure
---
### Configuration Validation
**Implementation:** `nextcloud_mcp_server/config_validators.py`
**Key Functions:**
```python
def detect_auth_mode(settings: Settings) -> AuthMode:
"""Detect authentication mode from configuration.
Priority (most specific to most general):
1. Smithery (explicit flag)
2. Token exchange (most specific OAuth mode)
3. Multi-user BasicAuth
4. Single-user BasicAuth
5. OAuth single-audience (default OAuth mode)
"""
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
"""Validate configuration for detected mode.
Returns:
Tuple of (detected_mode, list_of_errors)
Empty list means valid configuration.
"""
```
**Validation Rules:**
- **Required variables:** Must be set and non-empty
- **Forbidden variables:** Must NOT be set (or must be False for booleans)
- **Conditional requirements:** If feature X is enabled, requires variables Y and Z
**Error Messages:**
```
Configuration validation failed for {mode} mode:
- [{mode}] Missing required configuration: NEXTCLOUD_HOST
- [{mode}] ENABLE_OFFLINE_ACCESS must be enabled when VECTOR_SYNC_ENABLED is true
Mode: {mode}
Description: {mode_description}
Required configuration:
- VAR1
- VAR2
Optional configuration:
- VAR3
- VAR4
Conditional requirements:
When FEATURE is enabled:
- VAR5
- VAR6
```
**Integration:**
- Validation runs at app startup in `get_app()` (app.py:1048-1062)
- All errors reported before any initialization begins
- Mode-specific error messages explain requirements
- Validation uses the same Settings object used throughout the app
### Configuration Matrix
| Variable | Single BasicAuth | Multi BasicAuth | OAuth Single | OAuth Exchange | Smithery |
|----------|------------------|-----------------|--------------|----------------|----------|
| **NEXTCLOUD_HOST** | Required | Required | Required | Required | Forbidden |
| **NEXTCLOUD_USERNAME** | Required | Forbidden | Forbidden | Forbidden | Forbidden |
| **NEXTCLOUD_PASSWORD** | Required | Forbidden | Forbidden | Forbidden | Forbidden |
| **ENABLE_MULTI_USER_BASIC_AUTH** | Forbidden | Required | Forbidden | Forbidden | Forbidden |
| **ENABLE_TOKEN_EXCHANGE** | Forbidden | Forbidden | Forbidden | Required | Forbidden |
| **ENABLE_OFFLINE_ACCESS** | Optional\* | Optional\* | Optional\* | Optional\* | Forbidden |
| **TOKEN_ENCRYPTION_KEY** | If offline | If offline | If offline | If offline | Forbidden |
| **TOKEN_STORAGE_DB** | If offline | If offline | If offline | If offline | Forbidden |
| **OIDC_CLIENT_ID** | Forbidden | If offline | Optional\*\* | Optional\*\* | Forbidden |
| **OIDC_CLIENT_SECRET** | Forbidden | If offline | Optional\*\* | Optional\*\* | Forbidden |
| **VECTOR_SYNC_ENABLED** | Optional | Optional | Optional | Optional | Forbidden |
| **QDRANT_URL/LOCATION** | If vector | If vector | If vector | If vector | Forbidden |
| **OLLAMA_BASE_URL/OPENAI_API_KEY** | Optional | Optional | Optional | Optional | Forbidden |
\* Only enables background sync for semantic search
\*\* Uses DCR if not provided
## Consequences
### Positive
1. **Clarity:** Single function to detect mode from config
2. **Validation:** All config validated upfront with helpful errors
3. **Debugging:** Clear logs showing "Running in X mode with config Y"
4. **Maintenance:** Mode-specific logic can be isolated
5. **Documentation:** Clear mapping of mode → required config
6. **Error Messages:** Context-aware ("X is required for Y mode")
7. **Testing:** Each mode testable in isolation
### Negative
1. **Migration:** Existing invalid configurations will now fail at startup
2. **Flexibility:** Less flexibility in configuration combinations
3. **Strictness:** Some previously-working combinations may be rejected
### Neutral
1. **Backward Compatibility:** Valid configurations continue to work
2. **Mode Detection:** Automatic based on config (no explicit mode selection)
3. **Default Mode:** OAuth single-audience when no credentials provided
## Implementation Notes
### Embedding Provider Validation
Originally, validation required either `OLLAMA_BASE_URL` or `OPENAI_API_KEY` when vector sync was enabled. This was too strict because the Simple provider is always available as a fallback (ADR-015). The validation was removed to allow vector sync without explicit provider configuration.
### Variable Scoping Issues
During implementation, several Python variable scoping issues were discovered in `app.py`:
- Local variable assignments in `starlette_lifespan()` shadowed outer scope variables
- Fixed by using unique variable names (e.g., `nextcloud_host_for_context`, `basic_auth_storage`)
- Removed redundant `settings = get_settings()` call (re-used outer scope)
### Docker Compose Configuration
The `mcp-oauth` service configuration was updated to remove `ENABLE_MULTI_USER_BASIC_AUTH=true` which conflicted with its intended OAuth mode. The service now runs in OAuth single-audience mode with vector sync using the Simple embedding provider as fallback.
## Testing
### Unit Tests
`tests/unit/test_config_validators.py` provides comprehensive coverage:
- Mode detection with priority ordering (7 tests)
- Single-user BasicAuth validation (8 tests)
- Multi-user BasicAuth validation (7 tests)
- OAuth single-audience validation (6 tests)
- OAuth token exchange validation (3 tests)
- Smithery validation (4 tests)
- Mode summary generation (3 tests)
- Edge cases (3 tests)
**Total: 41 tests, all passing**
### Integration Tests
Integration tests verify that:
- Each mode starts successfully with valid configuration
- Invalid configurations fail with clear error messages
- Existing deployments continue to work
## References
- [ADR-002: Vector Sync Authentication](ADR-002-vector-sync-authentication.md)
- [ADR-004: Progressive Consent](ADR-004-progressive-consent.md)
- [ADR-015: Unified Provider Architecture](ADR-015-unified-provider-architecture.md)
- [ADR-019: Multi-user BasicAuth Pass-Through](ADR-019-multi-user-basicauth-passthrough.md)
- Implementation: `nextcloud_mcp_server/config_validators.py`
- Tests: `tests/unit/test_config_validators.py`
-391
View File
@@ -1,391 +0,0 @@
# ADR-021: Configuration Consolidation and Simplification
**Status:** Accepted
**Date:** 2025-12-21
**Deciders:** Development Team
**Related:** ADR-020 (Deployment Modes), ADR-002 (Vector Sync), ADR-004 (Progressive Consent)
## Context
The configuration system has grown complex with overlapping concerns that make it difficult for users to switch between deployment modes and understand configuration dependencies.
### Problems Identified
1. **Confusing variable names don't reflect purpose**:
- `ENABLE_OFFLINE_ACCESS` - Actually controls refresh token storage for background operations, not general "offline" capabilities
- `VECTOR_SYNC_ENABLED` - Controls semantic search background indexing (implementation detail, not user-facing feature name)
- Users struggle to understand what these variables actually control
2. **Redundant configuration requirements**:
- Multi-user semantic search requires setting BOTH `ENABLE_OFFLINE_ACCESS=true` AND `VECTOR_SYNC_ENABLED=true`
- The dependency is one-way (semantic search needs background ops, but background ops don't need semantic search)
- Users must understand internal implementation details to configure a user-facing feature
3. **Implicit mode detection creates ambiguity**:
- Five deployment modes detected via priority-based logic
- Users can't easily predict which mode will activate
- Configuration errors don't clearly indicate which mode triggered the requirement
4. **OIDC_CLIENT_ID vs NEXTCLOUD_OIDC_CLIENT_ID confusion**:
- Investigation revealed these are NOT actually overlapping (`OIDC_CLIENT_ID` is test-only)
- However, their similar names create confusion
### Current Configuration Complexity
**Example: Multi-user OAuth with semantic search**:
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true # Why is this needed?
VECTOR_SYNC_ENABLED=true # And this separately?
QDRANT_URL=http://qdrant:6333
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
Users must understand:
- Semantic search requires background token storage (ENABLE_OFFLINE_ACCESS)
- Background token storage requires encryption keys
- The relationship between ENABLE_OFFLINE_ACCESS and VECTOR_SYNC_ENABLED
- Which deployment mode these settings will activate
## Decision
We consolidate overlapping functionality and add explicit mode selection while maintaining 100% backward compatibility.
### 1. Automatic Dependency Resolution
**Make ENABLE_SEMANTIC_SEARCH the primary control** that automatically enables required dependencies:
**New behavior**:
```python
@property
def enable_background_operations(self) -> bool:
"""Background operations - auto-enabled by semantic search in multi-user modes."""
# Check new names first
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
# Fall back to old name with deprecation warning
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
# Auto-enable if semantic search needs it
auto_enabled = self.enable_semantic_search and self.is_multi_user_mode()
return explicit or legacy or auto_enabled
@property
def enable_semantic_search(self) -> bool:
"""Semantic search - renamed from VECTOR_SYNC_ENABLED."""
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
return new_value or old_value
```
**Result**: Users set `ENABLE_SEMANTIC_SEARCH=true` and the system automatically enables background token storage when needed.
### 2. Explicit Mode Selection (Optional)
Add `MCP_DEPLOYMENT_MODE` environment variable to remove detection ambiguity:
```bash
# Optional: Explicitly declare deployment mode
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Valid values: single_user_basic, multi_user_basic,
# oauth_single_audience, oauth_token_exchange, smithery
```
**Detection logic**:
1. If `MCP_DEPLOYMENT_MODE` is set → validate and use it
2. Otherwise → use priority-based auto-detection (existing behavior)
3. Validate explicit mode doesn't conflict with detected mode
### 3. Simplified User Experience
**Before**:
```bash
# Multi-user OAuth with semantic search
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true # Confusing
VECTOR_SYNC_ENABLED=true # Why both?
QDRANT_URL=http://qdrant:6333
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
**After**:
```bash
# Multi-user OAuth with semantic search
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background ops
QDRANT_URL=http://qdrant:6333
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
**Benefits**:
- 2 fewer variables to understand/set
- Clear intent ("I want semantic search")
- Explicit mode declaration (optional)
- All existing configs continue working
### 4. Variable Naming Strategy
**Deprecated (but still functional)**:
- `ENABLE_OFFLINE_ACCESS` → Renamed to `ENABLE_BACKGROUND_OPERATIONS`
- `VECTOR_SYNC_ENABLED` → Renamed to `ENABLE_SEMANTIC_SEARCH`
**No change needed**:
- `VECTOR_SYNC_SCAN_INTERVAL` - Implementation tuning parameter (keep as-is)
- `VECTOR_SYNC_PROCESSOR_WORKERS` - Implementation tuning parameter (keep as-is)
- `VECTOR_SYNC_QUEUE_MAX_SIZE` - Implementation tuning parameter (keep as-is)
**Rationale**: Only rename user-facing feature flags, not internal tuning parameters.
### 5. Backward Compatibility
**Support both old and new names for minimum 2 major versions**:
```python
@property
def enable_semantic_search(self) -> bool:
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
if new_value and old_value:
logger.warning(
"Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. "
"Using ENABLE_SEMANTIC_SEARCH. VECTOR_SYNC_ENABLED is deprecated."
)
if old_value and not new_value:
logger.warning(
"VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead."
)
return new_value or old_value
```
**Deprecation timeline**:
- v0.6.0: Add new variables, deprecate old ones (both work with warnings)
- v1.0.0: Remove old variables (breaking change, well-announced)
- Minimum 2 major versions of support (12+ months)
## Consequences
### Positive
1. **Reduced cognitive load**: Users set `ENABLE_SEMANTIC_SEARCH=true` instead of understanding internal dependencies
2. **Clearer intent**: Variable names reflect user-facing features, not implementation details
3. **Explicit mode control**: `MCP_DEPLOYMENT_MODE` removes detection ambiguity
4. **Better onboarding**: New users see simpler configuration in env.sample
5. **Improved error messages**: Validation can suggest "set MCP_DEPLOYMENT_MODE=X" instead of relying on implicit detection
6. **No breaking changes**: All existing configurations continue working
### Negative
1. **Transition period complexity**: Both old and new names supported for 2+ versions
2. **Documentation burden**: All docs must be updated to show new approach
3. **Test coverage expansion**: Must test both old and new variable names in all modes
4. **Migration effort**: Existing deployments should eventually migrate (optional but recommended)
### Neutral
1. **Same functionality**: No new features, just better organization
2. **Same validation**: Underlying requirements unchanged (e.g., semantic search still needs Qdrant)
3. **Same performance**: No runtime performance impact
## Implementation
### Phase 1: Configuration Consolidation (v0.6.0)
**Files to modify**:
- `nextcloud_mcp_server/config.py` - Add property-based deprecation with auto-enablement
- `nextcloud_mcp_server/config_validators.py` - Simplify validation (semantic search no longer requires explicit background operations setting)
- `nextcloud_mcp_server/app.py` - Add informative logging for auto-enablement
- `tests/unit/test_config_validators.py` - Add auto-enablement tests
- `docs/configuration-migration-v2.md` - Create migration guide
**Key changes**:
1. `enable_background_operations` property auto-enables when `enable_semantic_search=true` in multi-user modes
2. `enable_semantic_search` property accepts both `ENABLE_SEMANTIC_SEARCH` and `VECTOR_SYNC_ENABLED`
3. Smart logging when auto-enablement occurs or deprecated variables used
4. Validation simplified to remove redundant requirements
### Phase 2: Explicit Mode Selection (v0.6.0)
**Files to modify**:
- `nextcloud_mcp_server/config.py` - Add `deployment_mode` field
- `nextcloud_mcp_server/config_validators.py` - Check explicit mode first, fall back to auto-detection
- `tests/unit/test_config_validators.py` - Test mode override and conflict detection
- `docs/configuration.md` - Document mode selection
**Key changes**:
1. Add `MCP_DEPLOYMENT_MODE` environment variable (optional)
2. Mode detection checks explicit mode first, then auto-detects
3. Validate explicit mode doesn't conflict with detected mode
4. Better error messages referencing explicit mode setting
### Phase 3: env.sample Reorganization (v0.6.0)
**Files to create/modify**:
- `env.sample` - Reorganize by deployment mode
- `env.sample.single-user` - Simplest config template
- `env.sample.oauth-multi-user` - Multi-user template showing consolidation
- `env.sample.oauth-advanced` - Token exchange mode template
- `README.md` - Update Quick Start to reference templates
**Key changes**:
1. Group related settings by deployment mode
2. Show simplified configuration (only essential variables)
3. Document automatic dependencies inline
4. Provide mode-specific quick-start templates
### Phase 4: Documentation Updates (v0.7.0)
**Files to modify**:
- `docs/configuration.md` - Lead with consolidated approach
- `docs/authentication.md` - Update mode guidance with `MCP_DEPLOYMENT_MODE`
- `docs/troubleshooting.md` - Add consolidation troubleshooting section
- `docs/configuration-migration-v2.md` - Expand with comprehensive examples
- `docs/ADR-020-deployment-modes-and-configuration-validation.md` - Update configuration matrix
- All other ADRs - Update variable references
**Key changes**:
1. Update all examples to use new variable names
2. Add before/after migration examples
3. Document automatic dependency resolution
4. Add mode selection decision tree diagram
## Validation Strategy
### Test Coverage Requirements
**Backward compatibility tests**:
- Old variable names still work (ENABLE_OFFLINE_ACCESS, VECTOR_SYNC_ENABLED)
- New variable names work (ENABLE_BACKGROUND_OPERATIONS, ENABLE_SEMANTIC_SEARCH)
- Setting both old and new triggers deprecation warning but works correctly
- All 41 existing config validation tests pass
**Auto-enablement tests**:
- `ENABLE_SEMANTIC_SEARCH=true` in OAuth mode → `enable_background_operations=true`
- `ENABLE_SEMANTIC_SEARCH=true` in single-user mode → `enable_background_operations=false` (not needed)
- `ENABLE_SEMANTIC_SEARCH=false``enable_background_operations=false` (unless explicitly set)
**Mode selection tests**:
- `MCP_DEPLOYMENT_MODE=oauth_single_audience` → mode correctly detected
- `MCP_DEPLOYMENT_MODE` conflicts with detected mode → validation error
- No `MCP_DEPLOYMENT_MODE` → auto-detection works as before
## Success Metrics
**Immediate** (v0.6.0 release):
- Zero breaking changes in existing deployments
- All 41 config validation tests pass
- New users report clearer configuration process
**Medium-term** (6 months after v0.6.0):
- 80% of new deployments use new variable names
- Mode selection errors decrease by 50%
- Support requests about configuration decrease
**Long-term** (12+ months):
- 90% of deployments migrated to new names
- Old variable names can be safely removed in v1.0.0
- Configuration-related issues in issue tracker decrease
## Alternatives Considered
### Alternative 1: Just Rename Variables
**Rejected**: User feedback: "There's no reason to just rename variables without consolidating functionality"
This would make names clearer but wouldn't reduce the number of variables users need to set. The real problem is requiring users to set both ENABLE_OFFLINE_ACCESS and VECTOR_SYNC_ENABLED when they just want semantic search.
### Alternative 2: Remove ENABLE_OFFLINE_ACCESS Entirely
**Rejected**: Advanced users need background operations without semantic search
Some deployments might want background token storage for future features (background Deck sync, background Calendar sync, etc.) without enabling semantic search. Keeping ENABLE_BACKGROUND_OPERATIONS (renamed) allows this.
### Alternative 3: Always Auto-Enable Background Operations
**Rejected**: Single-user mode doesn't need background token storage
Auto-enablement is only needed in multi-user modes. Single-user mode uses a shared client with BasicAuth, so background token storage is unnecessary. Always enabling it would waste resources and create confusing log messages.
### Alternative 4: Require All New Names Immediately
**Rejected**: Breaking change would affect all existing deployments
Forcing migration to new variable names in v0.6.0 would break every existing deployment. Supporting both old and new names with deprecation warnings provides a smooth migration path.
## References
- [ADR-020: Deployment Modes and Configuration Validation](ADR-020-deployment-modes-and-configuration-validation.md)
- [ADR-002: Vector Sync Authentication](ADR-002-vector-sync-authentication.md)
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md)
- [Issue: Configuration complexity for multi-user semantic search](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/XXX)
## Migration Examples
### Example 1: Single-User BasicAuth with Semantic Search
**Before**:
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
VECTOR_SYNC_ENABLED=true
QDRANT_LOCATION=:memory:
```
**After** (optional migration):
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
ENABLE_SEMANTIC_SEARCH=true # Renamed
QDRANT_LOCATION=:memory:
# Note: Background operations NOT auto-enabled (not needed in single-user mode)
```
### Example 2: Multi-User OAuth with Semantic Search
**Before**:
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
QDRANT_URL=http://qdrant:6333
```
**After** (simplified):
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
QDRANT_URL=http://qdrant:6333
# Note: ENABLE_OFFLINE_ACCESS no longer needed (auto-enabled)
```
### Example 3: Multi-User OAuth WITHOUT Semantic Search
**Before**:
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true # For future background features
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
**After** (optional migration):
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience
ENABLE_BACKGROUND_OPERATIONS=true # Renamed for clarity
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
-564
View File
@@ -1,564 +0,0 @@
# Configuration Migration Guide v2
**Version:** v0.58.0
**Status:** Active
**Related ADR:** [ADR-021: Configuration Consolidation and Simplification](ADR-021-configuration-consolidation.md)
## Overview
This guide helps you migrate from the old configuration variables to the new consolidated approach introduced in v0.58.0.
**Key Changes:**
- `VECTOR_SYNC_ENABLED``ENABLE_SEMANTIC_SEARCH`
- `ENABLE_OFFLINE_ACCESS``ENABLE_BACKGROUND_OPERATIONS`
- New: `MCP_DEPLOYMENT_MODE` for explicit mode selection
- Automatic dependency resolution: semantic search auto-enables background operations
**Backward Compatibility:**
- Old variable names still work in v0.58.0+
- Deprecation warnings logged when old names used
- Old names will be removed in v1.0.0
---
## Quick Reference: Variable Name Changes
| Old Name | New Name | Status |
|----------|----------|--------|
| `VECTOR_SYNC_ENABLED` | `ENABLE_SEMANTIC_SEARCH` | Deprecated |
| `ENABLE_OFFLINE_ACCESS` | `ENABLE_BACKGROUND_OPERATIONS` | Deprecated |
| N/A (auto-detected) | `MCP_DEPLOYMENT_MODE` | New (optional) |
**Tuning parameters unchanged:**
- `VECTOR_SYNC_SCAN_INTERVAL` - Keep as-is
- `VECTOR_SYNC_PROCESSOR_WORKERS` - Keep as-is
- `VECTOR_SYNC_QUEUE_MAX_SIZE` - Keep as-is
---
## Migration Scenarios
### Scenario 1: Single-User BasicAuth with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
VECTOR_SYNC_ENABLED=true
QDRANT_LOCATION=:memory:
OLLAMA_BASE_URL=http://ollama:11434
```
**After (v0.58.0+):**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# Optional: Explicit mode declaration (recommended)
MCP_DEPLOYMENT_MODE=single_user_basic
# Updated variable name
ENABLE_SEMANTIC_SEARCH=true # Previously VECTOR_SYNC_ENABLED
QDRANT_LOCATION=:memory:
OLLAMA_BASE_URL=http://ollama:11434
```
**What Changed:**
- ✅ Renamed `VECTOR_SYNC_ENABLED` to `ENABLE_SEMANTIC_SEARCH`
- ✅ Added optional `MCP_DEPLOYMENT_MODE` for clarity
- ✅ Background operations NOT auto-enabled (not needed in single-user mode)
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Optionally add `MCP_DEPLOYMENT_MODE=single_user_basic`
3. Restart server
4. Verify deprecation warnings are gone
---
### Scenario 2: Multi-User OAuth with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Both variables required - confusing!
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**After (v0.58.0+ - Simplified):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=oauth_single_audience
# One variable does it all!
ENABLE_SEMANTIC_SEARCH=true # Automatically enables background operations
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
# Note: ENABLE_OFFLINE_ACCESS no longer needed!
# Background operations are auto-enabled by ENABLE_SEMANTIC_SEARCH
```
**What Changed:**
- ✅ Removed need for explicit `ENABLE_OFFLINE_ACCESS`
-`ENABLE_SEMANTIC_SEARCH` automatically enables background operations in multi-user modes
- ✅ Renamed `VECTOR_SYNC_ENABLED` to `ENABLE_SEMANTIC_SEARCH`
- ✅ Added optional explicit mode declaration
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
3. Optionally add `MCP_DEPLOYMENT_MODE=oauth_single_audience`
4. Restart server
5. Check logs for confirmation: "Automatically enabled background operations for semantic search"
---
### Scenario 3: Multi-User OAuth WITHOUT Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Enable background operations for future features
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**After (v0.58.0+):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Renamed for clarity
ENABLE_BACKGROUND_OPERATIONS=true # Previously ENABLE_OFFLINE_ACCESS
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**What Changed:**
- ✅ Renamed `ENABLE_OFFLINE_ACCESS` to `ENABLE_BACKGROUND_OPERATIONS`
- ✅ Added optional explicit mode declaration
**Migration Steps:**
1. Replace `ENABLE_OFFLINE_ACCESS=true` with `ENABLE_BACKGROUND_OPERATIONS=true`
2. Optionally add `MCP_DEPLOYMENT_MODE=oauth_single_audience`
3. Restart server
---
### Scenario 4: Multi-User BasicAuth with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
# Both required - redundant
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**After (v0.58.0+ - Simplified):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=multi_user_basic
# One variable handles both!
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
# Note: ENABLE_OFFLINE_ACCESS no longer needed!
```
**What Changed:**
- ✅ Semantic search auto-enables background operations
- ✅ Removed need for explicit `ENABLE_OFFLINE_ACCESS`
- ✅ Clearer variable naming
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
3. Optionally add `MCP_DEPLOYMENT_MODE=multi_user_basic`
4. Restart server
---
### Scenario 5: Token Exchange Mode with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# Both required
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
TOKEN_EXCHANGE_CACHE_TTL=300
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
```
**After (v0.58.0+ - Simplified):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=oauth_token_exchange
# One variable!
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
TOKEN_EXCHANGE_CACHE_TTL=300
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
```
**What Changed:**
- ✅ Semantic search auto-enables background operations
- ✅ Explicit mode declaration available
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
3. Optionally add `MCP_DEPLOYMENT_MODE=oauth_token_exchange`
4. Restart server
---
## Understanding Automatic Dependency Resolution
### How It Works
In v0.58.0+, the server uses smart dependency resolution:
```python
# In multi-user modes (OAuth, Multi-User BasicAuth):
if ENABLE_SEMANTIC_SEARCH == true:
background_operations = automatically enabled
refresh_tokens = automatically requested
token_storage = required (TOKEN_ENCRYPTION_KEY, TOKEN_STORAGE_DB)
oauth_credentials = required (for app password retrieval)
```
**What this means:**
- ✅ Set `ENABLE_SEMANTIC_SEARCH=true`
- ✅ Provide required infrastructure (Qdrant, Ollama, encryption key)
- ✅ System automatically enables background operations
- ❌ No need to set `ENABLE_BACKGROUND_OPERATIONS` separately
### When Automatic Enablement Happens
| Deployment Mode | Semantic Search Enabled | Background Operations Auto-Enabled? |
|----------------|------------------------|-----------------------------------|
| Single-User BasicAuth | ✅ | ❌ No (not needed) |
| Multi-User BasicAuth | ✅ | ✅ Yes |
| OAuth Single-Audience | ✅ | ✅ Yes |
| OAuth Token Exchange | ✅ | ✅ Yes |
| Smithery Stateless | N/A (not supported) | N/A |
### When to Explicitly Set ENABLE_BACKGROUND_OPERATIONS
Only needed when you want background operations **without** semantic search:
```bash
# Example: OAuth mode with background operations but NO semantic search
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Explicitly enable background operations for future features
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# Semantic search disabled
ENABLE_SEMANTIC_SEARCH=false
```
---
## Explicit Mode Selection
### Why Use MCP_DEPLOYMENT_MODE?
**Benefits:**
- ✅ Removes ambiguity about which mode is active
- ✅ Validation errors reference specific mode requirements
- ✅ Catches configuration mistakes early
- ✅ Self-documenting configuration
**Example:**
```bash
# Without explicit mode:
NEXTCLOUD_HOST=https://nextcloud.example.com
# Is this OAuth or Multi-User BasicAuth? Not immediately clear.
# With explicit mode:
MCP_DEPLOYMENT_MODE=oauth_single_audience
NEXTCLOUD_HOST=https://nextcloud.example.com
# Clear: This is OAuth mode
```
### Valid Mode Values
| Mode Value | Description |
|-----------|-------------|
| `single_user_basic` | Single-user with username/password |
| `multi_user_basic` | Multi-user with BasicAuth pass-through |
| `oauth_single_audience` | Multi-user OAuth (recommended) |
| `oauth_token_exchange` | Multi-user OAuth with token exchange |
| `smithery` | Smithery platform deployment |
### Mode Detection Priority
When `MCP_DEPLOYMENT_MODE` is set:
1. ✅ Explicit mode is used
2. ✅ Server validates configuration matches explicit mode
3. ❌ Auto-detection is skipped
When `MCP_DEPLOYMENT_MODE` is NOT set:
1. ✅ Auto-detection runs (existing behavior)
2. ✅ Priority: Smithery → Token Exchange → Multi-User BasicAuth → Single-User BasicAuth → OAuth Single-Audience
---
## Validation and Error Messages
### Old Validation (v0.57.x)
```
Error: [multi_user_basic] ENABLE_OFFLINE_ACCESS is required when VECTOR_SYNC_ENABLED is enabled
```
**Problem:** User must understand internal dependency relationship
### New Validation (v0.58.0+)
```
Error: [multi_user_basic] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
```
**Benefit:** Clear what's needed, no mention of internal ENABLE_BACKGROUND_OPERATIONS flag
---
## Troubleshooting Migration
### Issue: Deprecation Warning After Migration
**Symptom:**
```
WARNING: VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead.
```
**Solution:**
1. Check for `VECTOR_SYNC_ENABLED` in `.env` file
2. Replace with `ENABLE_SEMANTIC_SEARCH`
3. Search for any scripts/CI configs using old name
4. Restart server
### Issue: Both Old and New Names Set
**Symptom:**
```
WARNING: Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. Using ENABLE_SEMANTIC_SEARCH.
```
**Solution:**
1. Remove `VECTOR_SYNC_ENABLED` from `.env`
2. Keep `ENABLE_SEMANTIC_SEARCH`
3. Restart server
### Issue: Missing Required Dependencies
**Symptom:**
```
Error: [oauth_single_audience] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
```
**Solution:**
When semantic search is enabled in multi-user modes, you need:
- `TOKEN_ENCRYPTION_KEY` - Generate with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
- `TOKEN_STORAGE_DB` - Path to SQLite database (e.g., `/app/data/tokens.db`)
- `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` - For app password retrieval
### Issue: Unexpected Mode Detected
**Symptom:**
Server activates `oauth_single_audience` mode when you expected `multi_user_basic`
**Solution:**
Add explicit mode declaration:
```bash
MCP_DEPLOYMENT_MODE=multi_user_basic
ENABLE_MULTI_USER_BASIC_AUTH=true
```
---
## Testing Your Migration
### Step 1: Verify Configuration
```bash
# Set new variable names in .env
cat .env | grep -E "(ENABLE_SEMANTIC_SEARCH|ENABLE_BACKGROUND_OPERATIONS|MCP_DEPLOYMENT_MODE)"
```
### Step 2: Check for Old Variable Names
```bash
# Should return nothing after migration
cat .env | grep -E "(VECTOR_SYNC_ENABLED|ENABLE_OFFLINE_ACCESS)"
```
### Step 3: Start Server and Check Logs
```bash
# Start server
docker-compose up mcp
# Look for:
# 1. No deprecation warnings
# 2. Correct mode detected
# 3. Auto-enablement messages (if using semantic search in multi-user mode)
```
**Expected Log Output (Multi-User OAuth + Semantic Search):**
```
INFO: Using explicit deployment mode: oauth_single_audience
INFO: Automatically enabled background operations for semantic search in multi-user mode.
INFO: Vector sync enabled. Starting background scanner...
```
### Step 4: Verify Functionality
Test that existing features still work:
- [ ] Semantic search returns results
- [ ] Background indexing runs
- [ ] OAuth flow completes successfully
- [ ] Refresh tokens are stored/retrieved
---
## Quick Start Templates
We provide mode-specific templates for new deployments:
| Template | Use Case |
|----------|----------|
| `env.sample.single-user` | Simplest setup |
| `env.sample.oauth-multi-user` | Recommended multi-user |
| `env.sample.oauth-advanced` | Token exchange mode |
**Usage:**
```bash
cp env.sample.oauth-multi-user .env
# Edit .env with your values
docker-compose up -d
```
---
## Timeline and Support
| Version | Status | Old Variable Support |
|---------|--------|---------------------|
| v0.57.x | Stable | Old names only |
| v0.58.0 | Current | Both old and new (with warnings) |
| v1.0.0 | Breaking | New names only |
**Recommendation:** Migrate before v1.0.0 (12+ months minimum)
---
## Getting Help
If you encounter issues during migration:
1. **Check the logs** - Look for deprecation warnings and error messages
2. **Review ADR-021** - See [docs/ADR-021-configuration-consolidation.md](ADR-021-configuration-consolidation.md)
3. **Use mode-specific templates** - See `env.sample.*` files
4. **File an issue** - Include your `.env` (redacted), logs, and mode
---
## Summary
**What You Need to Do:**
1. ✅ Rename `VECTOR_SYNC_ENABLED``ENABLE_SEMANTIC_SEARCH`
2. ✅ (Optional) Rename `ENABLE_OFFLINE_ACCESS``ENABLE_BACKGROUND_OPERATIONS`
3. ✅ (Recommended) Add `MCP_DEPLOYMENT_MODE` for clarity
4. ✅ Remove redundant settings (semantic search auto-enables background ops in multi-user modes)
5. ✅ Test your configuration
**What the Server Does Automatically:**
- ✅ Supports both old and new variable names
- ✅ Logs deprecation warnings for old names
- ✅ Auto-enables background operations when semantic search is enabled in multi-user modes
- ✅ Validates configuration and provides clear error messages
**Migration Timeline:**
- Now → v1.0.0: Both old and new names work
- v1.0.0+: Only new names supported
**Questions?** See [docs/configuration.md](configuration.md) or file an issue.
+15 -129
View File
@@ -2,82 +2,25 @@
The Nextcloud MCP server requires configuration to connect to your Nextcloud instance. Configuration is provided through environment variables, typically stored in a `.env` file.
> **Note:** Configuration was significantly simplified in v0.58.0. If you're upgrading from v0.57.x, see the [Configuration Migration Guide](configuration-migration-v2.md).
## Quick Start
We provide mode-specific configuration templates for quick setup:
Create a `.env` file based on `env.sample`:
```bash
# Choose a template based on your deployment mode:
cp env.sample.single-user .env # Simplest - one user, local dev
cp env.sample.oauth-multi-user .env # Recommended - multi-user OAuth
cp env.sample.oauth-advanced .env # Advanced - token exchange mode
# Or start from the full example:
cp env.sample .env
# Edit .env with your Nextcloud details
```
Then choose your deployment mode:
Then choose your authentication mode:
- [Single-User BasicAuth](#single-user-basicauth-mode) - Simplest for personal instances
- [Multi-User OAuth](#multi-user-oauth-modes) - Recommended for production
- [Deployment Mode Selection](#deployment-mode-selection) - Explicit mode declaration
- [OAuth2/OIDC Configuration](#oauth2oidc-configuration) (Recommended)
- [Basic Authentication Configuration](#basic-authentication-legacy)
---
## Deployment Mode Selection
## OAuth2/OIDC Configuration
**New in v0.58.0:** You can explicitly declare your deployment mode to remove ambiguity and catch configuration errors early.
```dotenv
# Optional but recommended
MCP_DEPLOYMENT_MODE=oauth_single_audience
```
**Valid values:**
- `single_user_basic` - Single-user with username/password
- `multi_user_basic` - Multi-user with BasicAuth pass-through
- `oauth_single_audience` - Multi-user OAuth (recommended)
- `oauth_token_exchange` - Multi-user OAuth with token exchange
- `smithery` - Smithery platform deployment
**Benefits:**
- ✅ Clear which mode is active
- ✅ Better validation error messages
- ✅ Self-documenting configuration
- ✅ Catches configuration mistakes early
**Auto-detection:** If `MCP_DEPLOYMENT_MODE` is not set, the server auto-detects the mode based on other settings (existing behavior).
See [Authentication Modes](authentication.md) for detailed comparison of deployment modes.
---
## Single-User BasicAuth Mode
BasicAuth with a single user is the simplest deployment mode. Use for personal instances, local development, and testing.
```dotenv
# Minimal single-user configuration
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=single_user_basic
```
> [!WARNING]
> **Security Notice:** BasicAuth stores credentials in environment variables and is less secure than OAuth. Use OAuth for production multi-user deployments.
---
## Multi-User OAuth Modes
OAuth2/OIDC is the recommended authentication mode for production multi-user deployments.
OAuth2/OIDC is the recommended authentication mode for production deployments.
### Minimal Configuration (Auto-registration)
@@ -85,9 +28,6 @@ OAuth2/OIDC is the recommended authentication mode for production multi-user dep
# .env file for OAuth with auto-registration
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# Optional: Explicit mode declaration (recommended)
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Leave these EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
@@ -101,9 +41,6 @@ This minimal configuration uses dynamic client registration to automatically reg
# .env file for OAuth with pre-configured client
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# Optional: Explicit mode declaration (recommended)
MCP_DEPLOYMENT_MODE=oauth_single_audience
# OAuth Client Credentials (optional - auto-registers if not provided)
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
@@ -173,50 +110,8 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
## Semantic Search Configuration (Optional)
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
The MCP server includes semantic search capabilities powered by vector embeddings. This feature requires a vector database (Qdrant) and an embedding service.
### Quick Start
**Single-User Mode:**
```dotenv
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# Enable semantic search
ENABLE_SEMANTIC_SEARCH=true
# Vector database
QDRANT_LOCATION=:memory:
# Embedding provider
OLLAMA_BASE_URL=http://ollama:11434
```
**Multi-User OAuth Mode:**
```dotenv
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Enable semantic search
# In multi-user modes, this AUTOMATICALLY enables background operations!
ENABLE_SEMANTIC_SEARCH=true
# Required for background operations (auto-enabled by semantic search)
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# Vector database
QDRANT_URL=http://qdrant:6333
# Embedding provider
OLLAMA_BASE_URL=http://ollama:11434
```
> **Note:** In multi-user modes (OAuth, Multi-User BasicAuth), enabling `ENABLE_SEMANTIC_SEARCH` automatically enables background operations and refresh token storage. You don't need to set `ENABLE_BACKGROUND_OPERATIONS` separately!
### Qdrant Vector Database Modes
The server supports three Qdrant deployment modes:
@@ -231,7 +126,7 @@ No configuration needed! If neither `QDRANT_URL` nor `QDRANT_LOCATION` is set, t
```dotenv
# No Qdrant configuration needed - defaults to :memory:
ENABLE_SEMANTIC_SEARCH=true
VECTOR_SYNC_ENABLED=true
```
**Pros:**
@@ -250,7 +145,7 @@ For single-instance deployments that need persistence without a separate Qdrant
```dotenv
# Local persistent storage
QDRANT_LOCATION=/app/data/qdrant # Or any writable path
ENABLE_SEMANTIC_SEARCH=true
VECTOR_SYNC_ENABLED=true
```
**Pros:**
@@ -271,7 +166,7 @@ For production deployments with a dedicated Qdrant service:
QDRANT_URL=http://qdrant:6333
QDRANT_API_KEY=your-secret-api-key # Optional
QDRANT_COLLECTION=nextcloud_content # Optional
ENABLE_SEMANTIC_SEARCH=true
VECTOR_SYNC_ENABLED=true
```
**Pros:**
@@ -388,15 +283,13 @@ Solutions:
- Data corruption in Qdrant
- Confusing error messages during indexing
### Background Indexing Configuration
### Vector Sync Configuration
Control background indexing behavior:
```dotenv
# Semantic search (ADR-007, ADR-021)
ENABLE_SEMANTIC_SEARCH=true # Enable background indexing
# Tuning parameters (advanced - only modify if needed)
# Vector sync settings (ADR-007)
VECTOR_SYNC_ENABLED=true # Enable background indexing
VECTOR_SYNC_SCAN_INTERVAL=300 # Scan interval in seconds (default: 5 minutes)
VECTOR_SYNC_PROCESSOR_WORKERS=3 # Concurrent indexing workers (default: 3)
VECTOR_SYNC_QUEUE_MAX_SIZE=10000 # Max queued documents (default: 10000)
@@ -406,8 +299,6 @@ DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words between chunks (default: 50)
```
> **Note:** The `VECTOR_SYNC_*` tuning parameters keep their names as they're implementation details. Only the user-facing feature flag was renamed to `ENABLE_SEMANTIC_SEARCH`.
### Embedding Service Configuration
The server uses an embedding service to generate vector representations. Two options are available:
@@ -478,11 +369,11 @@ DOCUMENT_CHUNK_OVERLAP=100
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `ENABLE_SEMANTIC_SEARCH` | ⚠️ Optional | `false` | Enable semantic search with background indexing (replaces `VECTOR_SYNC_ENABLED`) |
| `QDRANT_URL` | ⚠️ Optional | - | Qdrant service URL (network mode) - mutually exclusive with `QDRANT_LOCATION` |
| `QDRANT_LOCATION` | ⚠️ Optional | `:memory:` | Local Qdrant path (`:memory:` or `/path/to/data`) - mutually exclusive with `QDRANT_URL` |
| `QDRANT_API_KEY` | ⚠️ Optional | - | Qdrant API key (network mode only) |
| `QDRANT_COLLECTION` | ⚠️ Optional | Auto-generated | Qdrant collection name |
| `QDRANT_COLLECTION` | ⚠️ Optional | `nextcloud_content` | Qdrant collection name |
| `VECTOR_SYNC_ENABLED` | ⚠️ Optional | `false` | Enable background vector indexing |
| `VECTOR_SYNC_SCAN_INTERVAL` | ⚠️ Optional | `300` | Document scan interval (seconds) |
| `VECTOR_SYNC_PROCESSOR_WORKERS` | ⚠️ Optional | `3` | Concurrent indexing workers |
| `VECTOR_SYNC_QUEUE_MAX_SIZE` | ⚠️ Optional | `10000` | Max queued documents |
@@ -492,9 +383,6 @@ DOCUMENT_CHUNK_OVERLAP=100
| `DOCUMENT_CHUNK_SIZE` | ⚠️ Optional | `512` | Words per chunk for document embedding |
| `DOCUMENT_CHUNK_OVERLAP` | ⚠️ Optional | `50` | Overlapping words between chunks (must be < chunk size) |
**Deprecated variables (still functional):**
- `VECTOR_SYNC_ENABLED` - Use `ENABLE_SEMANTIC_SEARCH` instead (will be removed in v1.0.0)
### Docker Compose Example
Enable network mode Qdrant with docker-compose:
@@ -504,7 +392,7 @@ services:
mcp:
environment:
- QDRANT_URL=http://qdrant:6333
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_ENABLED=true
qdrant:
image: qdrant/qdrant:latest
@@ -657,7 +545,6 @@ uv run nextcloud-mcp-server --no-oauth \
## See Also
- [Configuration Migration Guide v2](configuration-migration-v2.md) - **New in v0.58.0:** Migrate from old variable names
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute OAuth setup for development
- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in the MCP server
@@ -666,4 +553,3 @@ uv run nextcloud-mcp-server --no-oauth \
- [Running the Server](running.md) - Starting the server with different configurations
- [Troubleshooting](troubleshooting.md) - Common configuration issues
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting
- [ADR-021](ADR-021-configuration-consolidation.md) - Configuration consolidation architecture decision
-140
View File
@@ -4,146 +4,6 @@ This guide covers common issues and solutions for the Nextcloud MCP server.
> **OAuth-specific issues?** See the dedicated [OAuth Troubleshooting Guide](oauth-troubleshooting.md) for OAuth authentication problems, OIDC discovery issues, token validation failures, and more.
> **Upgrading from v0.57.x?** See the [Configuration Migration Guide](configuration-migration-v2.md) for help with new variable names.
## Configuration Issues (v0.58.0+)
### Issue: Deprecation warning for VECTOR_SYNC_ENABLED
**Symptom:**
```
WARNING: VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead.
```
**Cause:** You're using the old variable name from v0.57.x.
**Solution:**
```bash
# In your .env file, replace:
VECTOR_SYNC_ENABLED=true
# With:
ENABLE_SEMANTIC_SEARCH=true
```
See [Configuration Migration Guide](configuration-migration-v2.md) for complete migration instructions.
---
### Issue: Deprecation warning for ENABLE_OFFLINE_ACCESS
**Symptom:**
```
WARNING: ENABLE_OFFLINE_ACCESS is deprecated. Please use ENABLE_BACKGROUND_OPERATIONS instead.
```
**Cause:** You're using the old variable name from v0.57.x.
**Solution:**
**If you have semantic search enabled:**
```bash
# In multi-user modes, you can remove ENABLE_OFFLINE_ACCESS entirely!
# ENABLE_SEMANTIC_SEARCH automatically enables background operations
# Before (v0.57.x):
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
# After (v0.58.0+):
ENABLE_SEMANTIC_SEARCH=true # This is all you need!
```
**If you only want background operations (no semantic search):**
```bash
# Replace:
ENABLE_OFFLINE_ACCESS=true
# With:
ENABLE_BACKGROUND_OPERATIONS=true
```
---
### Issue: "Invalid MCP_DEPLOYMENT_MODE"
**Symptom:**
```
ValueError: Invalid MCP_DEPLOYMENT_MODE: 'oauth'. Valid values: single_user_basic, multi_user_basic, oauth_single_audience, oauth_token_exchange, smithery
```
**Cause:** Invalid value for `MCP_DEPLOYMENT_MODE`.
**Solution:**
Use one of the valid mode values:
```bash
# Correct values:
MCP_DEPLOYMENT_MODE=single_user_basic # Single-user with username/password
MCP_DEPLOYMENT_MODE=multi_user_basic # Multi-user BasicAuth
MCP_DEPLOYMENT_MODE=oauth_single_audience # OAuth (recommended)
MCP_DEPLOYMENT_MODE=oauth_token_exchange # OAuth with token exchange
MCP_DEPLOYMENT_MODE=smithery # Smithery deployment
```
Or remove `MCP_DEPLOYMENT_MODE` to use automatic detection.
---
### Issue: Missing TOKEN_ENCRYPTION_KEY when semantic search enabled
**Symptom:**
```
Error: [oauth_single_audience] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
```
**Cause:** In multi-user modes, semantic search automatically enables background operations, which require encrypted token storage.
**Solution:**
Generate an encryption key and add required token storage configuration:
```bash
# Generate encryption key
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Add to .env:
TOKEN_ENCRYPTION_KEY=<generated-key>
TOKEN_STORAGE_DB=/app/data/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id # Required for app password retrieval
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
```
**Why this happens:**
- v0.58.0+ automatically enables background operations when `ENABLE_SEMANTIC_SEARCH=true` in multi-user modes
- Background operations need encrypted refresh token storage
- This simplifies configuration but requires the encryption infrastructure
See [Configuration Guide - Semantic Search](configuration.md#semantic-search-configuration-optional) for details.
---
### Issue: Both old and new variable names set
**Symptom:**
```
WARNING: Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. Using ENABLE_SEMANTIC_SEARCH.
```
**Cause:** You have both the old and new variable names in your configuration.
**Solution:**
Remove the old variable name:
```bash
# Remove this line:
VECTOR_SYNC_ENABLED=true
# Keep this line:
ENABLE_SEMANTIC_SEARCH=true
```
The server will use the new name and ignore the old one, but it's cleaner to remove the old variable entirely.
---
## OAuth Issues (Quick Reference)
### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable"
+192 -225
View File
@@ -1,236 +1,203 @@
# ============================================
# DEPLOYMENT MODE SELECTION
# ============================================
# Optional: Explicitly declare deployment mode (ADR-021)
# If not set, mode is auto-detected from other settings
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
# oauth_token_exchange, smithery
#
# Recommendation: Set this for clarity and to catch configuration errors early
#MCP_DEPLOYMENT_MODE=oauth_single_audience
# ============================================
# COMMON SETTINGS (Required for all modes)
# ============================================
# Your Nextcloud instance URL (without trailing slash)
# Nextcloud Instance
NEXTCLOUD_HOST=
# ============================================
# SINGLE-USER BASICAUTH MODE
# ============================================
# Simplest deployment - one user, credentials in environment
# Use for: Personal instances, local development, testing
#
# Required:
# ===== AUTHENTICATION MODE =====
# Choose ONE of the following:
# Option 1: OAuth2/OIDC (RECOMMENDED - More Secure)
# - Requires Nextcloud OIDC app installed and configured
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB)
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
NEXTCLOUD_OIDC_CLIENT_ID=
NEXTCLOUD_OIDC_CLIENT_SECRET=
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens)
# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
#TOKEN_ENCRYPTION_KEY=
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
#TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== ADR-004 PROGRESSIVE CONSENT CONFIGURATION =====
# Enable Progressive Consent mode (dual OAuth flows)
# When enabled: Flow 1 for client auth, Flow 2 for Nextcloud resource access
# When disabled: Uses existing hybrid flow (backward compatible)
# MCP Server OAuth Client Configuration
# The MCP server's own OAuth client credentials for Flow 2
# If not set, will use dynamic client registration
#MCP_SERVER_CLIENT_ID=
#MCP_SERVER_CLIENT_SECRET=
# Allowed MCP Client IDs (comma-separated list)
# Client IDs that are allowed to authenticate in Flow 1
# Examples: claude-desktop,continue-dev,zed-editor
#ALLOWED_MCP_CLIENTS=claude-desktop,continue-dev,zed-editor
# Token cache configuration for Token Broker Service
# Cache TTL in seconds (default: 300 = 5 minutes)
#TOKEN_CACHE_TTL=300
# Early refresh threshold in seconds (default: 30)
#TOKEN_CACHE_EARLY_REFRESH=30
# Option 2: Basic Authentication (LEGACY - Less Secure)
# - Requires username and password
# - Credentials stored in environment variables
# - Use only for backward compatibility or if OAuth unavailable
# - If these are set, OAuth mode is disabled
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# MULTI-USER BASICAUTH MODE
# ============================================
# Users provide credentials in request headers (pass-through)
# Use for: Multi-user without OAuth, simple shared deployments
#
# Required:
#ENABLE_MULTI_USER_BASIC_AUTH=true
#
# Optional - Background Operations (for semantic search, future features):
# Enable background token storage using app passwords (via Astrolabe)
# Required for semantic search in multi-user mode
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
#ENABLE_BACKGROUND_OPERATIONS=true
#NEXTCLOUD_OIDC_CLIENT_ID=
#NEXTCLOUD_OIDC_CLIENT_SECRET=
#TOKEN_ENCRYPTION_KEY=
#TOKEN_STORAGE_DB=/app/data/tokens.db
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# OAUTH SINGLE-AUDIENCE MODE (Recommended)
# ============================================
# Multi-user OAuth with single-audience tokens
# Use for: Multi-user production deployments, enhanced security
# Tokens work for both MCP server and Nextcloud APIs (pass-through)
#
# Required: None (uses Dynamic Client Registration if credentials not provided)
#
# Optional - Pre-registered OAuth Client:
# If you pre-register the client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=
#NEXTCLOUD_OIDC_CLIENT_SECRET=
#
# Optional - Background Operations (for semantic search, future features):
# Enable refresh token storage for offline access
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
#ENABLE_BACKGROUND_OPERATIONS=true
#TOKEN_ENCRYPTION_KEY=
#TOKEN_STORAGE_DB=/app/data/tokens.db
#
# Optional - Custom OIDC Discovery:
# Auto-detected from NEXTCLOUD_HOST if not set
#NEXTCLOUD_OIDC_DISCOVERY_URL=
#
# Optional - Custom Scopes:
# Default: openid profile email offline_access notes:* calendar:* contacts:* tables:* webdav:* deck:* cookbook:*
#NEXTCLOUD_OIDC_SCOPES=openid profile email notes:* calendar:*
#
# MCP Server URL (for OAuth redirects):
#NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# OAUTH TOKEN EXCHANGE MODE (Advanced)
# ============================================
# Multi-user OAuth with RFC 8693 token exchange
# Use for: Advanced deployments requiring separate MCP and Nextcloud tokens
# MCP tokens are separate from Nextcloud tokens
#
# Required:
#ENABLE_TOKEN_EXCHANGE=true
#
# Optional - Pre-registered OAuth Client:
# If you pre-register the client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=
#NEXTCLOUD_OIDC_CLIENT_SECRET=
#
# Optional - Token Exchange Configuration:
# Cache TTL in seconds (default: 300 = 5 minutes)
#TOKEN_EXCHANGE_CACHE_TTL=300
#
# Optional - Background Operations:
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
#ENABLE_BACKGROUND_OPERATIONS=true
#TOKEN_ENCRYPTION_KEY=
#TOKEN_STORAGE_DB=/app/data/tokens.db
#
# Optional - Custom OIDC Discovery:
#NEXTCLOUD_OIDC_DISCOVERY_URL=
#
# MCP Server URL (for OAuth redirects):
#NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# SMITHERY STATELESS MODE
# ============================================
# Stateless multi-tenant deployment for Smithery platform
# Configuration comes from session URL parameters
# No persistent storage, no OAuth, no vector sync
#
# Required: None (all config from session URL)
# This mode is activated automatically when deployed to Smithery
# ============================================
# OPTIONAL FEATURES (All Deployment Modes)
# ============================================
# ===== SEMANTIC SEARCH =====
# AI-powered semantic search across Nextcloud content
# Requires: Qdrant vector database + embedding provider (Ollama, Bedrock, or Simple fallback)
#
# Enable semantic search:
#ENABLE_SEMANTIC_SEARCH=true
#
# Note for Multi-User Modes:
# ENABLE_SEMANTIC_SEARCH automatically enables background operations when needed
# No need to set ENABLE_BACKGROUND_OPERATIONS separately
# The server will automatically request refresh tokens and store them encrypted
#
# Vector Database - Choose ONE mode:
# 1. In-memory (default): Set neither QDRANT_URL nor QDRANT_LOCATION
# 2. Persistent local: Set QDRANT_LOCATION=/path/to/data
# 3. Network: Set QDRANT_URL=http://qdrant:6333
#
#QDRANT_URL=http://qdrant:6333
#QDRANT_LOCATION=:memory:
#QDRANT_API_KEY=
#QDRANT_COLLECTION=nextcloud_content
#
# Embedding Provider - Choose ONE:
# 1. Ollama (recommended for local deployment):
#OLLAMA_BASE_URL=http://ollama:11434
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
#OLLAMA_VERIFY_SSL=true
#
# 2. Amazon Bedrock (for AWS deployments):
#AWS_REGION=us-east-1
#BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
# Optional: AWS credentials (uses credential chain if not set)
#AWS_ACCESS_KEY_ID=
#AWS_SECRET_ACCESS_KEY=
#
# 3. Simple (automatic fallback, no configuration needed)
# Uses basic in-memory embeddings if no provider configured
#
# Document Chunking:
# Configure how documents are split before embedding
#DOCUMENT_CHUNK_SIZE=512
#DOCUMENT_CHUNK_OVERLAP=50
# ===== SEMANTIC SEARCH TUNING =====
# Advanced parameters for vector sync background operations
# Only modify if you understand the implications
#
# Document scan interval in seconds (default: 300 = 5 minutes)
#VECTOR_SYNC_SCAN_INTERVAL=300
#
# Concurrent indexing workers (default: 3)
#VECTOR_SYNC_PROCESSOR_WORKERS=3
#
# Max queued documents (default: 10000)
#VECTOR_SYNC_QUEUE_MAX_SIZE=10000
# ===== DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX, etc. for semantic search
# Disabled by default
#
#ENABLE_DOCUMENT_PROCESSING=false
#DOCUMENT_PROCESSOR=unstructured
#
# Unstructured.io Processor (recommended):
#ENABLE_UNSTRUCTURED=false
#UNSTRUCTURED_API_URL=http://unstructured:8000
#UNSTRUCTURED_TIMEOUT=120
#UNSTRUCTURED_STRATEGY=auto
#UNSTRUCTURED_LANGUAGES=eng,deu
#PROGRESS_INTERVAL=10
#
# Tesseract OCR (lightweight, images only):
#ENABLE_TESSERACT=false
#TESSERACT_CMD=/usr/bin/tesseract
#TESSERACT_LANG=eng
#
# Custom Processor (your own API):
#ENABLE_CUSTOM_PROCESSOR=false
#CUSTOM_PROCESSOR_NAME=my_ocr
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
#CUSTOM_PROCESSOR_API_KEY=
#CUSTOM_PROCESSOR_TIMEOUT=60
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
# ===== SECURITY & ADVANCED =====
# Cookie security (browser UI)
# Auto-detects from NEXTCLOUD_HOST protocol if not set
# Set explicitly for non-standard setups
#COOKIE_SECURE=true
# ============================================
# DEPRECATED VARIABLES (Backward Compatibility)
# Document Processing Configuration
# ============================================
# These variables still work but will be removed in v1.0.0
# Please migrate to new names:
#
# Old Name → New Name
# VECTOR_SYNC_ENABLED → ENABLE_SEMANTIC_SEARCH
# ENABLE_OFFLINE_ACCESS → ENABLE_BACKGROUND_OPERATIONS
#
# Migration is optional - both old and new names work
# Deprecation warnings will be logged when old names are used
# Enable document processing (PDF, DOCX, images, etc.)
# Set to false to disable all document processing
ENABLE_DOCUMENT_PROCESSING=false
# Default processor to use when multiple are available
# Options: unstructured, tesseract, custom
DOCUMENT_PROCESSOR=unstructured
# ============================================
# Unstructured.io Processor
# ============================================
# Enable Unstructured processor (requires unstructured service in docker-compose)
# This is a cloud-based/API processor supporting many document types
ENABLE_UNSTRUCTURED=false
# Unstructured API endpoint
UNSTRUCTURED_API_URL=http://unstructured:8000
# Request timeout in seconds (default: 120)
# OCR operations can take 30-120 seconds for large documents
UNSTRUCTURED_TIMEOUT=120
# Parsing strategy: auto, fast, hi_res
# - auto: Automatically choose based on document type
# - fast: Fast parsing without OCR
# - hi_res: High-resolution with OCR (slowest, most accurate)
UNSTRUCTURED_STRATEGY=auto
# OCR languages (comma-separated ISO 639-3 codes)
# Common: eng=English, deu=German, fra=French, spa=Spanish
UNSTRUCTURED_LANGUAGES=eng,deu
# Progress reporting interval in seconds (default: 10)
# During long-running OCR operations, progress notifications are sent to the MCP client
# at this interval to prevent timeouts and provide status updates
PROGRESS_INTERVAL=10
# ============================================
# Tesseract Processor (Local OCR)
# ============================================
# Enable Tesseract processor (requires tesseract binary installed)
# This is a local, lightweight OCR solution for images only
ENABLE_TESSERACT=false
# Path to tesseract executable (optional, auto-detected if in PATH)
#TESSERACT_CMD=/usr/bin/tesseract
# OCR language (e.g., eng, deu, eng+deu for multiple)
TESSERACT_LANG=eng
# ============================================
# Custom Processor (Your own API)
# ============================================
# Enable custom document processor via HTTP API
ENABLE_CUSTOM_PROCESSOR=false
# Unique name for your processor
#CUSTOM_PROCESSOR_NAME=my_ocr
# Your custom processor API endpoint
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
# Optional API key for authentication
#CUSTOM_PROCESSOR_API_KEY=your-api-key-here
# Request timeout in seconds
#CUSTOM_PROCESSOR_TIMEOUT=60
# Comma-separated MIME types your processor supports
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
# ============================================
# Semantic Search & Vector Sync Configuration
# ============================================
# EXPERIMENTAL: Semantic search for Notes app (multi-app support planned)
# Requires: Qdrant vector database + Ollama embedding service
# Disabled by default
# Enable background vector indexing
VECTOR_SYNC_ENABLED=false
# Document scan interval in seconds (default: 300 = 5 minutes)
# How often to check for new/updated documents
#VECTOR_SYNC_SCAN_INTERVAL=300
# Concurrent indexing workers (default: 3)
# Number of parallel workers for embedding generation
#VECTOR_SYNC_PROCESSOR_WORKERS=3
# Max queued documents (default: 10000)
# Maximum documents waiting to be processed
#VECTOR_SYNC_QUEUE_MAX_SIZE=10000
# ============================================
# Qdrant Vector Database Configuration
# ============================================
# Choose ONE of three modes:
# 1. In-memory mode (default): Set neither QDRANT_URL nor QDRANT_LOCATION
# 2. Persistent local: Set QDRANT_LOCATION=/path/to/data
# 3. Network mode: Set QDRANT_URL=http://qdrant:6333
# Network mode: URL to Qdrant service
#QDRANT_URL=http://qdrant:6333
# Local mode: Path to store vectors (use :memory: for in-memory)
#QDRANT_LOCATION=:memory:
# API key for network mode (optional)
#QDRANT_API_KEY=
# Collection name (optional - auto-generated if not set)
# Auto-generation format: {deployment-id}-{model-name}
# Allows safe model switching and multi-server deployments
#QDRANT_COLLECTION=nextcloud_content
# ============================================
# Ollama Embedding Service Configuration
# ============================================
# Ollama endpoint for embeddings (if not set, uses SimpleEmbeddingProvider fallback)
#OLLAMA_BASE_URL=http://ollama:11434
# Embedding model to use (default: nomic-embed-text, 768 dimensions)
# Changing this creates a new collection (requires re-embedding all documents)
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Verify SSL certificates (default: true)
#OLLAMA_VERIFY_SSL=true
# ============================================
# Document Chunking Configuration
# ============================================
# Configure how documents are split before embedding
# Words per chunk (default: 512)
# Smaller chunks (256-384): More precise, less context, more storage
# Larger chunks (768-1024): More context, less precise, less storage
#DOCUMENT_CHUNK_SIZE=512
# Overlapping words between chunks (default: 50)
# Recommended: 10-20% of chunk size
# Preserves context across chunk boundaries
#DOCUMENT_CHUNK_OVERLAP=50
-80
View File
@@ -1,80 +0,0 @@
# ============================================
# OAUTH TOKEN EXCHANGE QUICK START (Advanced)
# ============================================
# Advanced OAuth deployment with RFC 8693 token exchange
# Use for: Deployments requiring separate MCP and Nextcloud tokens
# Features: Dual-audience tokens, enhanced security boundaries
#
# Copy this file to .env and configure
# ===== REQUIRED SETTINGS =====
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=https://nextcloud.example.com
# Enable token exchange mode
ENABLE_TOKEN_EXCHANGE=true
# ===== REQUIRED: LEAVE USERNAME/PASSWORD EMPTY =====
# OAuth mode activates when these are NOT set
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended for clarity
MCP_DEPLOYMENT_MODE=oauth_token_exchange
# ===== OPTIONAL: PRE-REGISTERED OAUTH CLIENT =====
# If you pre-register the OAuth client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
#NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# MCP Server URL (for OAuth redirects)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# ===== OPTIONAL: TOKEN EXCHANGE TUNING =====
# Cache TTL for exchanged tokens (default: 300 seconds = 5 minutes)
TOKEN_EXCHANGE_CACHE_TTL=300
# ===== OPTIONAL: SEMANTIC SEARCH =====
# AI-powered semantic search with automatic background operation setup
#
# Note: ENABLE_SEMANTIC_SEARCH automatically enables background operations
# in token exchange mode, just like in OAuth single-audience mode
#
ENABLE_SEMANTIC_SEARCH=true
# Vector Database (required for semantic search)
QDRANT_URL=http://qdrant:6333
# Embedding Provider (required for semantic search)
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Token Storage (required for background operations - auto-enabled by semantic search)
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
#ENABLE_UNSTRUCTURED=true
#UNSTRUCTURED_API_URL=http://unstructured:8000
# ===== TOKEN EXCHANGE MODE EXPLANATION =====
# In this mode:
# 1. MCP clients authenticate with tokens scoped to "mcp-server" audience
# 2. Server exchanges MCP tokens for Nextcloud tokens on each request
# 3. Provides clear separation between MCP session and Nextcloud access
# 4. Enables fine-grained token lifecycle management
#
# When to use:
# - Strict security requirements (separate token contexts)
# - Complex multi-service architectures
# - Need independent token expiration policies
#
# When NOT to use:
# - Simple deployments (use oauth_single_audience instead)
# - High-performance requirements (token exchange adds latency)
# For more configuration options, see env.sample
-77
View File
@@ -1,77 +0,0 @@
# ============================================
# OAUTH MULTI-USER QUICK START (Recommended)
# ============================================
# Multi-user deployment with OAuth authentication
# Use for: Multi-user production deployments, enhanced security
# Features: Single-audience tokens, automatic client registration (DCR)
#
# Copy this file to .env and configure
# ===== REQUIRED SETTINGS =====
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=https://nextcloud.example.com
# ===== REQUIRED: LEAVE USERNAME/PASSWORD EMPTY =====
# OAuth mode activates when these are NOT set
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended for clarity
MCP_DEPLOYMENT_MODE=oauth_single_audience
# ===== OPTIONAL: PRE-REGISTERED OAUTH CLIENT =====
# If you pre-register the OAuth client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
#NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# MCP Server URL (for OAuth redirects)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# ===== OPTIONAL: SEMANTIC SEARCH (Recommended) =====
# AI-powered semantic search with automatic background operation setup
#
# When you enable semantic search in multi-user mode:
# 1. ENABLE_SEMANTIC_SEARCH automatically enables background operations
# 2. Server requests refresh tokens for offline indexing
# 3. Tokens are stored encrypted in TOKEN_STORAGE_DB
# 4. No need to set ENABLE_BACKGROUND_OPERATIONS separately!
#
ENABLE_SEMANTIC_SEARCH=true
# Vector Database (required for semantic search)
QDRANT_URL=http://qdrant:6333
# OR for in-memory mode:
#QDRANT_LOCATION=:memory:
# Embedding Provider (required for semantic search)
# Option 1: Ollama (recommended for local deployment)
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Option 2: Amazon Bedrock (for AWS deployments)
#AWS_REGION=us-east-1
#BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
# Token Storage (required for background operations - auto-enabled by semantic search)
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
#ENABLE_UNSTRUCTURED=true
#UNSTRUCTURED_API_URL=http://unstructured:8000
# ===== SUMMARY OF AUTO-ENABLEMENT =====
# With ENABLE_SEMANTIC_SEARCH=true in OAuth mode:
# ✅ Background operations enabled automatically
# ✅ Refresh token storage enabled automatically
# ✅ OAuth credentials required (DCR or pre-registered)
# ✅ Encryption key required for token storage
#
# You only need to set ENABLE_SEMANTIC_SEARCH and provide the required
# infrastructure (Qdrant, Ollama, encryption key). The rest is automatic!
# For more advanced configuration, see env.sample
-37
View File
@@ -1,37 +0,0 @@
# ============================================
# SINGLE-USER BASICAUTH QUICK START
# ============================================
# Simplest deployment mode - one user, credentials in environment
# Use for: Personal instances, local development, testing
#
# Copy this file to .env and fill in your credentials
# ===== REQUIRED SETTINGS =====
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=http://localhost:8080
# Your Nextcloud credentials
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended to avoid ambiguity
MCP_DEPLOYMENT_MODE=single_user_basic
# ===== OPTIONAL: SEMANTIC SEARCH =====
# Uncomment to enable AI-powered semantic search
# Requires: Qdrant + embedding provider (Ollama or Bedrock)
#
#ENABLE_SEMANTIC_SEARCH=true
#QDRANT_LOCATION=:memory:
#OLLAMA_BASE_URL=http://ollama:11434
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
#ENABLE_UNSTRUCTURED=true
#UNSTRUCTURED_API_URL=http://unstructured:8000
# That's it! Single-user mode is the simplest to configure.
# For more options, see env.sample
+5 -18
View File
@@ -182,23 +182,14 @@ async def get_server_status(request: Request) -> JSONResponse:
# Calculate uptime
uptime_seconds = int(time.time() - _server_start_time)
# Determine auth mode using proper mode detection
from nextcloud_mcp_server.config_validators import AuthMode, detect_auth_mode
# Determine auth mode
nextcloud_username = os.getenv("NEXTCLOUD_USERNAME")
nextcloud_password = os.getenv("NEXTCLOUD_PASSWORD")
mode = detect_auth_mode(settings)
# Map deployment mode to auth_mode for API response
# This helps clients (like Astrolabe) determine which auth flow to use
if mode == AuthMode.OAUTH_SINGLE_AUDIENCE or mode == AuthMode.OAUTH_TOKEN_EXCHANGE:
auth_mode = "oauth"
elif mode == AuthMode.MULTI_USER_BASIC:
auth_mode = "multi_user_basic"
elif mode == AuthMode.SINGLE_USER_BASIC:
if nextcloud_username and nextcloud_password:
auth_mode = "basic"
elif mode == AuthMode.SMITHERY_STATELESS:
auth_mode = "smithery"
else:
auth_mode = "unknown"
auth_mode = "oauth"
response_data = {
"version": __version__,
@@ -208,10 +199,6 @@ async def get_server_status(request: Request) -> JSONResponse:
"management_api_version": "1.0",
}
# Add app password support indicator for multi-user BasicAuth mode
if mode == AuthMode.MULTI_USER_BASIC:
response_data["supports_app_passwords"] = settings.enable_offline_access
# Include OIDC configuration if in OAuth mode
if auth_mode == "oauth":
# Provide IdP discovery information for NC PHP app
+209 -448
View File
@@ -1,7 +1,6 @@
import logging
import os
import time
import traceback
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
from contextvars import ContextVar
@@ -42,14 +41,10 @@ from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import (
DeploymentMode,
get_deployment_mode,
get_document_processor_config,
get_settings,
)
from nextcloud_mcp_server.config_validators import (
AuthMode,
get_mode_summary,
validate_configuration,
)
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 (
@@ -356,52 +351,6 @@ def get_smithery_session_config() -> dict | None:
return _smithery_session_config.get()
class BasicAuthMiddleware:
"""Middleware to extract BasicAuth credentials from Authorization header.
For multi-user BasicAuth pass-through mode, this middleware extracts
username/password from the Authorization: Basic header and stores them
in the request state for use by the context layer.
The credentials are NOT stored persistently - they are passed through
directly to Nextcloud APIs for each request (stateless).
"""
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(
self, scope: StarletteScope, receive: Receive, send: Send
) -> None:
if scope["type"] == "http":
# Extract Authorization header
headers = dict(scope.get("headers", []))
auth_header = headers.get(b"authorization", b"")
if auth_header.startswith(b"Basic "):
try:
import base64
# Decode base64(username:password)
encoded = auth_header[6:] # Skip "Basic "
decoded = base64.b64decode(encoded).decode("utf-8")
username, password = decoded.split(":", 1)
# Store in request state
scope.setdefault("state", {})
scope["state"]["basic_auth"] = {
"username": username,
"password": password,
}
logger.debug(
f"BasicAuth credentials extracted for user: {username}"
)
except Exception as e:
logger.warning(f"Failed to extract BasicAuth credentials: {e}")
await self.app(scope, receive, send)
class SmitheryConfigMiddleware:
"""Middleware to extract Smithery config from URL query parameters.
@@ -474,6 +423,41 @@ async def app_lifespan_smithery(server: FastMCP) -> AsyncIterator[SmitheryAppCon
logger.info("Shutting down Smithery stateless mode")
def is_oauth_mode() -> bool:
"""
Determine if OAuth mode should be used.
OAuth mode is enabled when:
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
- AND we are NOT in Smithery stateless mode
- Or explicitly enabled via configuration
Returns:
True if OAuth mode, False if BasicAuth mode
"""
# ADR-016: Smithery stateless mode uses per-request BasicAuth from session config
# It's not OAuth mode even though env credentials aren't set
deployment_mode = get_deployment_mode()
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
logger.info(
"BasicAuth mode (Smithery stateless - credentials from session config)"
)
return False
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
# If both username and password are set, use BasicAuth
if username and password:
logger.info(
"BasicAuth mode detected (NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD set)"
)
return False
logger.info("OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)")
return True
async def load_oauth_client_credentials(
nextcloud_host: str, registration_endpoint: str | None
) -> tuple[str, str]:
@@ -594,31 +578,17 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
"""
Manage application lifecycle for BasicAuth mode (FastMCP session lifespan).
For single-user mode: Creates a single Nextcloud client with basic authentication
Creates a single Nextcloud client with basic authentication
that is shared across all requests within a session.
For multi-user mode: No shared client - clients created per-request by BasicAuthMiddleware.
Note: Background tasks (scanner, processor) are started at server level
in starlette_lifespan, not here. This lifespan runs per-session.
"""
settings = get_settings()
is_multi_user = settings.enable_multi_user_basic_auth
logger.info("Starting MCP session in BasicAuth mode")
logger.info("Creating Nextcloud client with BasicAuth")
logger.info(
f"Starting MCP session in {'multi-user' if is_multi_user else 'single-user'} BasicAuth mode"
)
# Only create shared client for single-user mode
client = None
if not is_multi_user:
logger.info("Creating shared Nextcloud client with BasicAuth")
client = NextcloudClient.from_env()
logger.info("Client initialization complete")
else:
logger.info(
"Multi-user mode - clients created per-request from BasicAuth headers"
)
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
@@ -634,7 +604,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
# Include vector sync state from module singleton (set by starlette_lifespan)
try:
yield AppContext(
client=client, # type: ignore[arg-type] # None in multi-user mode
client=client,
storage=storage,
document_send_stream=_vector_sync_state.document_send_stream,
document_receive_stream=_vector_sync_state.document_receive_stream,
@@ -643,8 +613,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
)
finally:
logger.info("Shutting down BasicAuth session")
if client is not None:
await client.close()
await client.close()
async def setup_oauth_config():
@@ -1016,33 +985,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Initialize observability (logging will be configured by uvicorn)
settings = get_settings()
# Validate configuration and detect deployment mode
mode, config_errors = validate_configuration(settings)
if config_errors:
error_msg = (
f"Configuration validation failed for {mode.value} mode:\n"
+ "\n".join(f" - {err}" for err in config_errors)
+ "\n\n"
+ get_mode_summary(mode)
)
logger.error(error_msg)
raise ValueError(error_msg)
logger.info(f"✅ Configuration validated successfully for {mode.value} mode")
logger.debug(f"Mode details:\n{get_mode_summary(mode)}")
# Derive helper variables for backward compatibility with existing code
oauth_enabled = mode in (
AuthMode.OAUTH_SINGLE_AUDIENCE,
AuthMode.OAUTH_TOKEN_EXCHANGE,
)
deployment_mode = (
DeploymentMode.SMITHERY_STATELESS
if mode == AuthMode.SMITHERY_STATELESS
else DeploymentMode.SELF_HOSTED
)
# Setup Prometheus metrics (always enabled by default)
if settings.metrics_enabled:
setup_metrics(port=settings.metrics_port)
@@ -1066,77 +1008,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
)
# Initialize OAuth credentials for multi-user modes that need background operations
# This must happen BEFORE uvicorn starts (same lifecycle point as OAuth modes)
# to avoid async context issues
multi_user_basic_oauth_creds: tuple[str, str] | None = None
# Determine authentication mode and deployment mode
oauth_enabled = is_oauth_mode()
deployment_mode = get_deployment_mode()
if (
mode == AuthMode.MULTI_USER_BASIC
and settings.vector_sync_enabled
and settings.enable_offline_access
):
print(
f"DEBUG: Multi-user BasicAuth mode detected, vector_sync={settings.vector_sync_enabled}, offline_access={settings.enable_offline_access}"
)
logger.info(
"Multi-user BasicAuth with vector sync - checking for OAuth credentials"
)
# Check for static credentials first
static_client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
static_client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
if static_client_id and static_client_secret:
print("DEBUG: Using static OAuth credentials")
logger.info("Using static OAuth credentials for background operations")
multi_user_basic_oauth_creds = (static_client_id, static_client_secret)
else:
# Perform DCR before uvicorn starts (same lifecycle as OAuth modes)
print("DEBUG: No static credentials, attempting DCR...")
logger.info(
"OAuth credentials not configured - attempting Dynamic Client Registration..."
)
import anyio
async def setup_multi_user_basic_dcr():
"""Setup DCR for multi-user BasicAuth background operations."""
# Construct registration endpoint directly from nextcloud_host
# Standard RFC 7591 endpoint pattern for Nextcloud OIDC
# This avoids relying on discovery doc which may use public URLs unreachable from containers
registration_endpoint = f"{settings.nextcloud_host}/apps/oidc/register"
logger.info(
f"Attempting Dynamic Client Registration at: {registration_endpoint}"
)
# Perform DCR
try:
# Assert nextcloud_host is not None (required for multi-user mode)
assert settings.nextcloud_host is not None, (
"NEXTCLOUD_HOST is required"
)
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=settings.nextcloud_host,
registration_endpoint=registration_endpoint,
)
logger.info(
f"✓ Dynamic Client Registration successful for background operations "
f"(client_id: {client_id[:16]}...)"
)
return (client_id, client_secret)
except Exception as e:
logger.error(f"Dynamic Client Registration failed: {e}")
logger.debug(f"Full traceback:\n{traceback.format_exc()}")
logger.warning("Background vector sync will be disabled.")
return None
# Run DCR synchronously before uvicorn starts
multi_user_basic_oauth_creds = anyio.run(setup_multi_user_basic_dcr)
# Create MCP server based on detected mode
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
if oauth_enabled:
logger.info("Configuring MCP server for OAuth mode")
# Asynchronously get the OAuth configuration
import anyio
@@ -1199,32 +1075,33 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
enable_dns_rebinding_protection=False
),
)
elif mode == AuthMode.SMITHERY_STATELESS:
logger.info("Configuring MCP server for Smithery stateless mode")
# json_response=True returns plain JSON-RPC instead of SSE format,
# required for Smithery scanner compatibility
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_smithery,
json_response=True,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
else:
# BasicAuth modes (single-user or multi-user)
logger.info(f"Configuring MCP server for {mode.value} mode")
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_basic,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
logger.info("Configuring MCP server for Smithery stateless mode")
# json_response=True returns plain JSON-RPC instead of SSE format,
# required for Smithery scanner compatibility
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_smithery,
json_response=True,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
else:
logger.info("Configuring MCP server for BasicAuth mode")
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_basic,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
@mcp.resource("nc://capabilities")
async def nc_get_capabilities():
@@ -1262,6 +1139,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Register semantic search tools (cross-app feature)
# ADR-016: Skip in Smithery stateless mode (no vector database)
settings = get_settings()
deployment_mode = get_deployment_mode()
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
logger.info("Skipping semantic search tools (Smithery stateless mode)")
elif settings.vector_sync_enabled:
@@ -1348,20 +1227,13 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Set OAuth context for OAuth login routes (ADR-004)
if oauth_enabled:
# Prepare OAuth config from setup_oauth_config closure variables
# Get nextcloud_host from settings (it was validated as required)
nextcloud_host_for_context = settings.nextcloud_host
if not nextcloud_host_for_context:
raise ValueError("NEXTCLOUD_HOST is required for OAuth mode")
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
nextcloud_resource_uri = os.getenv(
"NEXTCLOUD_RESOURCE_URI", nextcloud_host_for_context
)
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host_for_context}/.well-known/openid-configuration",
f"{nextcloud_host}/.well-known/openid-configuration",
)
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
@@ -1375,7 +1247,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"client_id": client_id, # From setup_oauth_config (DCR or static)
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
"scopes": scopes,
"nextcloud_host": nextcloud_host_for_context,
"nextcloud_host": nextcloud_host,
"nextcloud_resource_uri": nextcloud_resource_uri,
"oauth_provider": oauth_provider,
},
@@ -1398,66 +1270,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
)
else:
# BasicAuth mode - initialize storage for webhook management
# BasicAuth mode - share storage with browser_app for webhook management
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
basic_auth_storage = RefreshTokenStorage.from_env()
await basic_auth_storage.initialize()
logger.info("Initialized refresh token storage for webhook management")
storage = RefreshTokenStorage.from_env()
await storage.initialize()
app.state.storage = basic_auth_storage
# For multi-user BasicAuth with offline access, create oauth_context for management APIs
# This allows Astrolabe to use management APIs with OAuth bearer tokens
if settings.enable_multi_user_basic_auth and settings.enable_offline_access:
# Check if we have OAuth credentials from DCR
if multi_user_basic_oauth_creds:
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
# Create minimal oauth_context for management API authentication
nextcloud_host_for_context = settings.nextcloud_host
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host_for_context}/.well-known/openid-configuration",
)
oauth_context_dict = {
"storage": basic_auth_storage,
"oauth_client": None, # Not needed for management APIs
"token_verifier": None, # Will be set when token broker is created
"config": {
"mcp_server_url": mcp_server_url,
"discovery_url": discovery_url,
"client_id": sync_client_id,
"client_secret": sync_client_secret,
"scopes": "", # Background sync only
"nextcloud_host": nextcloud_host_for_context,
"nextcloud_resource_uri": nextcloud_host_for_context,
"oauth_provider": "nextcloud", # Always Nextcloud for multi-user BasicAuth
},
}
app.state.oauth_context = oauth_context_dict
logger.info(
f"OAuth context initialized for management APIs (multi-user BasicAuth, client_id={sync_client_id[:16]}...)"
)
app.state.storage = storage
# Also share with browser_app for webhook routes
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
browser_app = cast(Starlette, route.app)
browser_app.state.storage = basic_auth_storage
if (
settings.enable_multi_user_basic_auth
and settings.enable_offline_access
and hasattr(app.state, "oauth_context")
):
browser_app.state.oauth_context = app.state.oauth_context
logger.info(
"OAuth context shared with browser_app for management APIs"
)
browser_app.state.storage = storage
logger.info(
"Storage shared with browser_app for webhook management"
)
@@ -1467,17 +1292,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Scanner runs at server-level (once), not per-session
import anyio as anyio_module
# Re-use settings from outer scope (already validated)
# Note: enable_offline_access_for_sync, encryption_key, and refresh_token_storage
# are already defined in outer scope before mode split
settings = get_settings()
# Multi-user BasicAuth uses OAuth-style background sync (with app passwords)
# So skip single-user BasicAuth vector sync if in multi-user mode
if (
settings.vector_sync_enabled
and not oauth_enabled
and not settings.enable_multi_user_basic_auth
):
# Check if vector sync is enabled and determine the mode
enable_offline_access_for_sync = os.getenv(
"ENABLE_OFFLINE_ACCESS", "false"
).lower() in ("true", "1", "yes")
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
if settings.vector_sync_enabled and not oauth_enabled:
# BasicAuth mode - single user sync
logger.info("Starting background vector sync tasks for BasicAuth mode")
@@ -1577,13 +1400,13 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
elif (
settings.vector_sync_enabled
and (oauth_enabled or settings.enable_multi_user_basic_auth)
and settings.enable_offline_access
and oauth_enabled
and enable_offline_access_for_sync
and refresh_token_storage
and encryption_key
):
# OAuth mode with offline access - multi-user sync
# Also used for multi-user BasicAuth mode (client auth is BasicAuth, background sync uses app passwords)
mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode"
logger.info(f"Starting background vector sync tasks for {mode_desc}")
logger.info("Starting background vector sync tasks for OAuth mode")
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.vector.oauth_sync import (
@@ -1591,178 +1414,138 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
user_manager_task,
)
# Get nextcloud_host (from settings - already validated)
nextcloud_host_for_sync = settings.nextcloud_host
if not nextcloud_host_for_sync:
raise ValueError("NEXTCLOUD_HOST required for vector sync")
# Get OIDC discovery URL (same as used for OAuth setup)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host_for_sync}/.well-known/openid-configuration",
f"{nextcloud_host}/.well-known/openid-configuration",
)
# Get client credentials - these were obtained before uvicorn started
# For OAuth modes: from setup_oauth_config()
# For multi-user BasicAuth: from setup_multi_user_basic_dcr()
# Get client credentials from oauth_context (set by setup_oauth_config)
# This includes credentials from DCR if dynamic registration was used
# Use different variable names to avoid shadowing client_id/client_secret from outer scope
oauth_ctx = getattr(app.state, "oauth_context", {})
oauth_config = oauth_ctx.get("config", {})
sync_client_id = oauth_config.get("client_id")
sync_client_secret = oauth_config.get("client_secret")
# For multi-user BasicAuth mode, use pre-obtained credentials from outer scope
if not sync_client_id or not sync_client_secret:
if multi_user_basic_oauth_creds:
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
logger.info(
"Using pre-obtained OAuth credentials for background sync"
)
else:
# No credentials available - DCR was attempted before uvicorn started but failed
sync_client_id = None
sync_client_secret = None
logger.warning(
"OAuth credentials not available for background sync "
"(DCR was attempted during startup but failed)"
)
logger.error(
"Cannot start OAuth vector sync: client credentials not found in oauth_context"
)
raise ValueError("OAuth client credentials required for vector sync")
# Only start vector sync if credentials are available
if sync_client_id and sync_client_secret:
# Get storage - different for OAuth vs multi-user BasicAuth modes
# OAuth mode: refresh_token_storage (from setup_oauth_config)
# Multi-user BasicAuth: app.state.storage (basic_auth_storage)
token_storage = (
refresh_token_storage if oauth_enabled else app.state.storage
# Create token broker for background operations
# Note: storage handles encryption internally, no key needed here
# Client credentials are needed for token refresh operations
token_broker = TokenBrokerService(
storage=refresh_token_storage,
oidc_discovery_url=discovery_url,
nextcloud_host=nextcloud_host,
client_id=sync_client_id,
client_secret=sync_client_secret,
)
# Store token broker in oauth_context for management API (revoke endpoint)
if hasattr(app.state, "oauth_context"):
app.state.oauth_context["token_broker"] = token_broker
logger.info("Token broker added to oauth_context for management API")
# Initialize Qdrant collection before starting background tasks
logger.info("Initializing Qdrant collection...")
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
try:
await get_qdrant_client() # Triggers collection creation if needed
logger.info("Qdrant collection ready")
except Exception as e:
logger.error(f"Failed to initialize Qdrant collection: {e}")
raise RuntimeError(
f"Cannot start vector sync - Qdrant initialization failed: {e}"
) from e
# Initialize shared state
send_stream, receive_stream = anyio_module.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size
)
shutdown_event = anyio_module.Event()
scanner_wake_event = anyio_module.Event()
# User state tracking for user manager
user_states: dict = {}
# Store in app state for access from routes (ADR-007)
app.state.document_send_stream = send_stream
app.state.document_receive_stream = receive_stream
app.state.shutdown_event = shutdown_event
app.state.scanner_wake_event = scanner_wake_event
# Also store in module singleton for FastMCP session lifespans
_vector_sync_state.document_send_stream = send_stream
_vector_sync_state.document_receive_stream = receive_stream
_vector_sync_state.shutdown_event = shutdown_event
_vector_sync_state.scanner_wake_event = scanner_wake_event
logger.info("Vector sync state stored in module singleton")
# Also share with browser_app for /app route
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
browser_app = cast(Starlette, route.app)
browser_app.state.document_send_stream = send_stream
browser_app.state.document_receive_stream = receive_stream
browser_app.state.shutdown_event = shutdown_event
browser_app.state.scanner_wake_event = scanner_wake_event
logger.info("Vector sync state shared with browser_app for /app")
break
# Start background tasks using anyio TaskGroup
async with anyio_module.create_task_group() as tg:
# Start user manager task (supervises per-user scanners)
await tg.start(
user_manager_task,
send_stream,
shutdown_event,
scanner_wake_event,
token_broker,
refresh_token_storage,
nextcloud_host,
user_states,
tg,
)
# Create token broker for background operations
# Note: storage handles encryption internally, no key needed here
# Client credentials are needed for token refresh operations
token_broker = TokenBrokerService(
storage=token_storage,
oidc_discovery_url=discovery_url,
nextcloud_host=nextcloud_host_for_sync,
client_id=sync_client_id,
client_secret=sync_client_secret,
)
# Store token broker in oauth_context for management API (revoke endpoint)
if hasattr(app.state, "oauth_context"):
app.state.oauth_context["token_broker"] = token_broker
logger.info(
"Token broker added to oauth_context for management API"
)
# Initialize Qdrant collection before starting background tasks
logger.info("Initializing Qdrant collection...")
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
try:
await get_qdrant_client() # Triggers collection creation if needed
logger.info("Qdrant collection ready")
except Exception as e:
logger.error(f"Failed to initialize Qdrant collection: {e}")
raise RuntimeError(
f"Cannot start vector sync - Qdrant initialization failed: {e}"
) from e
# Initialize shared state
send_stream, receive_stream = anyio_module.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size
)
shutdown_event = anyio_module.Event()
scanner_wake_event = anyio_module.Event()
# User state tracking for user manager
user_states: dict = {}
# Store in app state for access from routes (ADR-007)
app.state.document_send_stream = send_stream
app.state.document_receive_stream = receive_stream
app.state.shutdown_event = shutdown_event
app.state.scanner_wake_event = scanner_wake_event
# Also store in module singleton for FastMCP session lifespans
_vector_sync_state.document_send_stream = send_stream
_vector_sync_state.document_receive_stream = receive_stream
_vector_sync_state.shutdown_event = shutdown_event
_vector_sync_state.scanner_wake_event = scanner_wake_event
logger.info("Vector sync state stored in module singleton")
# Also share with browser_app for /app route
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
browser_app = cast(Starlette, route.app)
browser_app.state.document_send_stream = send_stream
browser_app.state.document_receive_stream = receive_stream
browser_app.state.shutdown_event = shutdown_event
browser_app.state.scanner_wake_event = scanner_wake_event
logger.info(
"Vector sync state shared with browser_app for /app"
)
break
# Start background tasks using anyio TaskGroup
async with anyio_module.create_task_group() as tg:
# Start user manager task (supervises per-user scanners)
# Start processor pool (each gets a cloned receive stream)
for i in range(settings.vector_sync_processor_workers):
await tg.start(
user_manager_task,
send_stream,
oauth_processor_task,
i,
receive_stream.clone(),
shutdown_event,
scanner_wake_event,
token_broker,
token_storage, # Use token_storage (works for both OAuth and multi-user BasicAuth)
nextcloud_host_for_sync,
user_states,
tg,
nextcloud_host,
)
# Start processor pool (each gets a cloned receive stream)
for i in range(settings.vector_sync_processor_workers):
await tg.start(
oauth_processor_task,
i,
receive_stream.clone(),
shutdown_event,
token_broker,
nextcloud_host_for_sync,
)
logger.info(
f"Background sync tasks started: 1 user manager + "
f"{settings.vector_sync_processor_workers} processors"
)
# Run MCP session manager and yield
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
try:
yield
finally:
# Shutdown signal
logger.info("Shutting down background sync tasks")
shutdown_event.set()
# Close token broker HTTP client
if token_broker._http_client:
await token_broker._http_client.aclose()
# TaskGroup automatically cancels all tasks on exit
else:
# No OAuth credentials available for background sync
logger.warning(
"Skipping background vector sync - OAuth credentials not available. "
"Multi-user BasicAuth mode will run without semantic search background operations. "
"To enable, set NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET."
logger.info(
f"Background sync tasks started: 1 user manager + "
f"{settings.vector_sync_processor_workers} processors"
)
# Just run MCP session manager without vector sync
# Run MCP session manager and yield
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
yield
try:
yield
finally:
# Shutdown signal
logger.info("Shutting down background sync tasks")
shutdown_event.set()
# Close token broker HTTP client
if token_broker._http_client:
await token_broker._http_client.aclose()
# TaskGroup automatically cancels all tasks on exit
else:
# No vector sync - just run MCP session manager
if settings.vector_sync_enabled:
# Log why vector sync is not starting
if oauth_enabled and not settings.enable_offline_access:
if oauth_enabled and not enable_offline_access_for_sync:
logger.warning(
"Vector sync enabled but ENABLE_OFFLINE_ACCESS=false - "
"vector sync requires offline access in OAuth mode"
@@ -1771,7 +1554,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
logger.warning(
"Vector sync enabled but refresh token storage not available"
)
elif oauth_enabled and not os.getenv("TOKEN_ENCRYPTION_KEY"):
elif oauth_enabled and not encryption_key:
logger.warning(
"Vector sync enabled but TOKEN_ENCRYPTION_KEY not set"
)
@@ -1834,20 +1617,12 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
is_ready = False
# Check authentication configuration
# Report the deployment mode, not just whether OAuth is enabled
# This helps clients (like Astrolabe) determine which auth flow to use
if (
mode == AuthMode.OAUTH_SINGLE_AUDIENCE
or mode == AuthMode.OAUTH_TOKEN_EXCHANGE
):
if oauth_enabled:
# OAuth mode - just verify we got this far (token_verifier initialized in lifespan)
checks["auth_mode"] = "oauth"
checks["auth_configured"] = "ok"
elif mode == AuthMode.MULTI_USER_BASIC:
checks["auth_mode"] = "multi_user_basic"
checks["auth_configured"] = "ok"
# Indicate if app passwords are supported (when offline_access enabled)
checks["supports_app_passwords"] = settings.enable_offline_access
elif mode == AuthMode.SINGLE_USER_BASIC:
else:
# BasicAuth mode - verify credentials are set
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
if username and password:
@@ -1857,9 +1632,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
checks["auth_mode"] = "basic"
checks["auth_configured"] = "error: credentials not set"
is_ready = False
elif mode == AuthMode.SMITHERY_STATELESS:
checks["auth_mode"] = "smithery"
checks["auth_configured"] = "ok"
# Check Qdrant status if using network mode (external Qdrant service)
# In-memory and persistent modes use embedded Qdrant, no external service to check
@@ -1941,12 +1713,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
# Add management API endpoints for Nextcloud PHP app
# Available in: OAuth modes OR multi-user BasicAuth with offline access (for Astrolabe integration)
enable_management_apis = oauth_enabled or (
settings.enable_multi_user_basic_auth and settings.enable_offline_access
)
if enable_management_apis:
# Add management API endpoints for Nextcloud PHP app (OAuth mode only)
if oauth_enabled:
from nextcloud_mcp_server.api.management import (
create_webhook,
delete_webhook,
@@ -2373,11 +2141,4 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
app = SmitheryConfigMiddleware(app)
logger.info("SmitheryConfigMiddleware enabled for query parameter config")
# Apply BasicAuthMiddleware for multi-user BasicAuth pass-through mode
if settings.enable_multi_user_basic_auth:
app = BasicAuthMiddleware(app)
logger.info(
"BasicAuthMiddleware enabled - multi-user BasicAuth pass-through mode active"
)
return app
@@ -1,152 +0,0 @@
"""
Client for querying Astrolabe Management API for background sync credentials.
This client uses OAuth client credentials flow to authenticate to Nextcloud
and retrieve user app passwords for background sync operations.
"""
import logging
import time
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
class AstrolabeClient:
"""Client for querying Astrolabe API for background sync credentials.
Uses OAuth client credentials flow to authenticate as the MCP server
and retrieve user app passwords that are stored in Nextcloud.
"""
def __init__(
self,
nextcloud_host: str,
client_id: str,
client_secret: str,
):
"""
Initialize Astrolabe client.
Args:
nextcloud_host: Nextcloud base URL (e.g., https://cloud.example.com)
client_id: OAuth client ID for MCP server
client_secret: OAuth client secret
"""
self.nextcloud_host = nextcloud_host.rstrip("/")
self.client_id = client_id
self.client_secret = client_secret
self._token_cache: Optional[dict] = None # {access_token, expires_at}
async def get_access_token(self) -> str:
"""
Get access token using OAuth client credentials flow.
Tokens are cached with 1-minute early refresh to avoid expiration.
Returns:
Access token string
Raises:
httpx.HTTPError: If token request fails
"""
# Check cache
if self._token_cache and time.time() < self._token_cache["expires_at"]:
logger.debug("Using cached OAuth token for Astrolabe API")
return self._token_cache["access_token"]
# Discover token endpoint
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
async with httpx.AsyncClient() as client:
logger.debug(f"Discovering token endpoint from {discovery_url}")
discovery_resp = await client.get(discovery_url)
discovery_resp.raise_for_status()
token_endpoint = discovery_resp.json()["token_endpoint"]
logger.debug(f"Requesting client credentials token from {token_endpoint}")
# Request token using client credentials grant
token_resp = await client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "openid", # Minimal scope
},
)
token_resp.raise_for_status()
data = token_resp.json()
# Cache with 1-minute early refresh
expires_in = data.get("expires_in", 3600)
self._token_cache = {
"access_token": data["access_token"],
"expires_at": time.time() + expires_in - 60,
}
logger.info(f"Obtained Astrolabe API token (expires in {expires_in}s)")
return data["access_token"]
async def get_user_app_password(self, user_id: str) -> Optional[str]:
"""
Retrieve user's app password for background sync.
Args:
user_id: Nextcloud user ID
Returns:
App password string, or None if user hasn't provisioned
Raises:
httpx.HTTPError: If API request fails (except 404)
"""
token = await self.get_access_token()
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
async with httpx.AsyncClient() as client:
logger.debug(f"Retrieving app password for user: {user_id}")
response = await client.get(
url,
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
)
if response.status_code == 404:
logger.debug(f"No app password configured for user: {user_id}")
return None
response.raise_for_status()
data = response.json()
logger.info(
f"Retrieved app password for user: {user_id} (type: {data.get('credential_type')})"
)
return data.get("app_password")
async def get_background_sync_status(self, user_id: str) -> dict:
"""
Get background sync status for a user.
Args:
user_id: Nextcloud user ID
Returns:
Dict with keys: has_access, credential_type, provisioned_at
Raises:
httpx.HTTPError: If API request fails
"""
# For now, check if app password exists
# In the future, this could query a dedicated status endpoint
app_password = await self.get_user_app_password(user_id)
return {
"has_access": app_password is not None,
"credential_type": "app_password" if app_password else None,
"provisioned_at": None, # TODO: Get from API if available
}
+5 -134
View File
@@ -163,12 +163,6 @@ def get_document_processor_config() -> dict[str, Any]:
class Settings:
"""Application settings from environment variables."""
# Deployment mode (ADR-021: explicit mode selection)
# Optional: If not set, mode is auto-detected from other settings
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
# oauth_token_exchange, smithery
deployment_mode: Optional[str] = None
# OAuth/OIDC settings
oidc_discovery_url: Optional[str] = None
oidc_client_id: Optional[str] = None
@@ -193,11 +187,6 @@ class Settings:
enable_token_exchange: bool = False
enable_offline_access: bool = False
# Multi-user BasicAuth pass-through mode (ADR-019 interim solution)
# When enabled, MCP server extracts BasicAuth credentials from request headers
# and passes them through to Nextcloud APIs (no storage, stateless)
enable_multi_user_basic_auth: bool = False
# Token exchange cache settings
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
@@ -357,131 +346,13 @@ class Settings:
return f"{deployment_id}-{model_name}"
def _get_semantic_search_enabled() -> bool:
"""Get semantic search enabled status, supporting both old and new variable names.
Supports:
- ENABLE_SEMANTIC_SEARCH (new, preferred)
- VECTOR_SYNC_ENABLED (old, deprecated)
Returns:
True if semantic search should be enabled
"""
logger = logging.getLogger(__name__)
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
if new_value and old_value:
logger.warning(
"Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. "
"Using ENABLE_SEMANTIC_SEARCH. "
"VECTOR_SYNC_ENABLED is deprecated and will be removed in v1.0.0."
)
elif old_value and not new_value:
logger.warning(
"VECTOR_SYNC_ENABLED is deprecated. "
"Please use ENABLE_SEMANTIC_SEARCH instead. "
"Support for VECTOR_SYNC_ENABLED will be removed in v1.0.0."
)
return new_value or old_value
def _is_multi_user_mode() -> bool:
"""Detect if this is a multi-user deployment mode.
Multi-user modes are:
- Multi-user BasicAuth (ENABLE_MULTI_USER_BASIC_AUTH=true)
- OAuth Single-Audience (no username/password set)
- OAuth Token Exchange (ENABLE_TOKEN_EXCHANGE=true)
Single-user modes are:
- Single-user BasicAuth (username and password both set)
- Smithery Stateless (SMITHERY_DEPLOYMENT=true)
Returns:
True if multi-user mode detected
"""
# Smithery is always single-user (stateless)
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
return False
# Multi-user BasicAuth explicitly enabled
if os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true":
return True
# Token exchange implies OAuth multi-user
if os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true":
return True
# If both username and password are set, it's single-user BasicAuth
has_username = bool(os.getenv("NEXTCLOUD_USERNAME"))
has_password = bool(os.getenv("NEXTCLOUD_PASSWORD"))
if has_username and has_password:
return False
# Otherwise, assume OAuth multi-user (default when no credentials provided)
return True
def _get_background_operations_enabled() -> bool:
"""Get background operations enabled status with auto-enablement for semantic search.
Supports:
- ENABLE_BACKGROUND_OPERATIONS (new, preferred)
- ENABLE_OFFLINE_ACCESS (old, deprecated)
- Auto-enabled if ENABLE_SEMANTIC_SEARCH=true in multi-user modes
Returns:
True if background operations should be enabled
"""
logger = logging.getLogger(__name__)
# Check new and old variable names
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
if explicit and legacy:
logger.warning(
"Both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS are set. "
"Using ENABLE_BACKGROUND_OPERATIONS. "
"ENABLE_OFFLINE_ACCESS is deprecated and will be removed in v1.0.0."
)
elif legacy and not explicit:
logger.warning(
"ENABLE_OFFLINE_ACCESS is deprecated. "
"Please use ENABLE_BACKGROUND_OPERATIONS instead. "
"Support for ENABLE_OFFLINE_ACCESS will be removed in v1.0.0."
)
# Auto-enable if semantic search is enabled in multi-user mode
semantic_search_enabled = _get_semantic_search_enabled()
is_multi_user = _is_multi_user_mode()
auto_enabled = semantic_search_enabled and is_multi_user
if auto_enabled and not (explicit or legacy):
logger.info(
"Automatically enabled background operations for semantic search in multi-user mode. "
"Set ENABLE_BACKGROUND_OPERATIONS=false to disable (this will also disable semantic search)."
)
return explicit or legacy or auto_enabled
def get_settings() -> Settings:
"""Get application settings from environment variables.
Returns:
Settings object with configuration values
"""
# Get consolidated values with smart dependency resolution
enable_semantic_search = _get_semantic_search_enabled()
enable_background_operations = _get_background_operations_enabled()
return Settings(
# Deployment mode (ADR-021)
deployment_mode=os.getenv("MCP_DEPLOYMENT_MODE"),
# OAuth/OIDC settings
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"),
@@ -502,10 +373,8 @@ def get_settings() -> Settings:
enable_token_exchange=(
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
),
enable_offline_access=enable_background_operations, # Smart dependency resolution
# Multi-user BasicAuth pass-through mode
enable_multi_user_basic_auth=(
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
enable_offline_access=(
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
),
# Token exchange cache settings
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
@@ -513,7 +382,9 @@ def get_settings() -> Settings:
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
# Vector sync settings (ADR-007)
vector_sync_enabled=enable_semantic_search, # Smart dependency resolution
vector_sync_enabled=(
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
),
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")),
vector_sync_processor_workers=int(
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3")
-460
View File
@@ -1,460 +0,0 @@
"""Configuration validation and mode detection for the MCP server.
This module provides:
- Mode detection based on configuration
- Configuration validation with clear error messages
- Single source of truth for deployment mode requirements
See ADR-020 for detailed architecture and deployment mode documentation.
"""
import logging
from dataclasses import dataclass
from enum import Enum
from nextcloud_mcp_server.config import Settings
logger = logging.getLogger(__name__)
class AuthMode(Enum):
"""Authentication mode for the MCP server.
Determines how users authenticate and how the server accesses Nextcloud.
"""
SINGLE_USER_BASIC = "single_user_basic"
MULTI_USER_BASIC = "multi_user_basic"
OAUTH_SINGLE_AUDIENCE = "oauth_single"
OAUTH_TOKEN_EXCHANGE = "oauth_exchange"
SMITHERY_STATELESS = "smithery"
@dataclass
class ModeRequirements:
"""Requirements for a deployment mode.
Attributes:
required: Configuration variables that must be set
optional: Configuration variables that may be set
forbidden: Configuration variables that should not be set
conditional: Additional requirements based on feature flags
Format: {feature_flag: [required_vars]}
description: Human-readable description of the mode
"""
required: list[str]
optional: list[str]
forbidden: list[str]
conditional: dict[str, list[str]]
description: str
# Mode requirements definition
MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
AuthMode.SINGLE_USER_BASIC: ModeRequirements(
required=["nextcloud_host", "nextcloud_username", "nextcloud_password"],
optional=[
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
"document_chunk_size",
"document_chunk_overlap",
],
forbidden=[
"enable_multi_user_basic_auth",
"enable_token_exchange",
"oidc_client_id",
"oidc_client_secret",
],
conditional={
"vector_sync_enabled": [
# Either qdrant_url OR qdrant_location (checked in Settings.__post_init__)
# At least one embedding provider (ollama_base_url OR openai_api_key)
],
},
description="Single-user deployment with BasicAuth credentials. "
"Suitable for personal Nextcloud instances and local development.",
),
AuthMode.MULTI_USER_BASIC: ModeRequirements(
required=["nextcloud_host", "enable_multi_user_basic_auth"],
optional=[
# Background sync with app passwords (via Astrolabe)
"enable_offline_access",
"token_encryption_key",
"token_storage_db",
"oidc_client_id",
"oidc_client_secret",
# Vector sync
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
],
forbidden=[
"nextcloud_username",
"nextcloud_password",
"enable_token_exchange",
],
conditional={
"enable_offline_access": [
# OAuth credentials validated separately (lines 397-406) with clearer error message
"token_encryption_key",
"token_storage_db",
],
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
# enables background operations in multi-user modes. No explicit
# enable_offline_access setting required.
},
description="Multi-user deployment with BasicAuth pass-through. "
"Users provide credentials in request headers. "
"Optional background sync using app passwords stored via Astrolabe.",
),
AuthMode.OAUTH_SINGLE_AUDIENCE: ModeRequirements(
required=["nextcloud_host"],
optional=[
# OAuth credentials (uses DCR if not provided)
"oidc_client_id",
"oidc_client_secret",
"oidc_discovery_url",
# Offline access
"enable_offline_access",
"token_encryption_key",
"token_storage_db",
# Vector sync
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
# Scopes
"nextcloud_oidc_scopes",
],
forbidden=[
"nextcloud_username",
"nextcloud_password",
"enable_token_exchange",
"enable_multi_user_basic_auth",
],
conditional={
"enable_offline_access": [
"token_encryption_key",
"token_storage_db",
],
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
# enables background operations in multi-user modes. No explicit
# enable_offline_access setting required.
},
description="OAuth multi-user deployment with single-audience tokens. "
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
"Uses Dynamic Client Registration if credentials not provided.",
),
AuthMode.OAUTH_TOKEN_EXCHANGE: ModeRequirements(
required=["nextcloud_host", "enable_token_exchange"],
optional=[
# OAuth credentials
"oidc_client_id",
"oidc_client_secret",
"oidc_discovery_url",
# Token exchange settings
"token_exchange_cache_ttl",
# Offline access
"enable_offline_access",
"token_encryption_key",
"token_storage_db",
# Vector sync
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
],
forbidden=[
"nextcloud_username",
"nextcloud_password",
"enable_multi_user_basic_auth",
],
conditional={
"enable_offline_access": [
"token_encryption_key",
"token_storage_db",
],
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
# enables background operations in multi-user modes. No explicit
# enable_offline_access setting required.
},
description="OAuth multi-user deployment with token exchange (RFC 8693). "
"MCP tokens are separate from Nextcloud tokens. "
"Server exchanges MCP token for Nextcloud token on each request.",
),
AuthMode.SMITHERY_STATELESS: ModeRequirements(
required=[], # All config from session URL params
optional=[],
forbidden=[
"nextcloud_host",
"nextcloud_username",
"nextcloud_password",
"enable_multi_user_basic_auth",
"enable_token_exchange",
"enable_offline_access",
"vector_sync_enabled",
"oidc_client_id",
"oidc_client_secret",
],
conditional={},
description="Stateless multi-tenant deployment for Smithery platform. "
"Configuration comes from session URL parameters. "
"No persistent storage, no OAuth, no vector sync.",
),
}
def detect_auth_mode(settings: Settings) -> AuthMode:
"""Detect authentication mode from configuration.
Mode detection priority (ADR-021):
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
1. Smithery (explicit flag)
2. Token exchange (most specific OAuth mode)
3. Multi-user BasicAuth
4. Single-user BasicAuth
5. OAuth single-audience (default OAuth mode)
Args:
settings: Application settings
Returns:
Detected AuthMode
Raises:
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
"""
import logging
import os
logger = logging.getLogger(__name__)
# ADR-021: Check for explicit deployment mode first
if settings.deployment_mode:
mode_str = settings.deployment_mode.lower().strip()
# Map string to AuthMode enum
mode_map = {
"single_user_basic": AuthMode.SINGLE_USER_BASIC,
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
"oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE,
"oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE,
"smithery": AuthMode.SMITHERY_STATELESS,
}
if mode_str not in mode_map:
valid_modes = ", ".join(mode_map.keys())
raise ValueError(
f"Invalid MCP_DEPLOYMENT_MODE: '{settings.deployment_mode}'. "
f"Valid values: {valid_modes}"
)
explicit_mode = mode_map[mode_str]
logger.info(f"Using explicit deployment mode: {explicit_mode.value}")
return explicit_mode
# Auto-detection (existing behavior)
# Check for Smithery mode (explicit environment variable)
# Note: This checks the environment directly, not settings
# because Smithery mode has no settings-based config
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
return AuthMode.SMITHERY_STATELESS
# Check for token exchange (most specific OAuth mode)
if settings.enable_token_exchange:
return AuthMode.OAUTH_TOKEN_EXCHANGE
# Check for multi-user BasicAuth
if settings.enable_multi_user_basic_auth:
return AuthMode.MULTI_USER_BASIC
# Check for single-user BasicAuth (explicit credentials)
if settings.nextcloud_username and settings.nextcloud_password:
return AuthMode.SINGLE_USER_BASIC
# Default: OAuth single-audience mode
# This is the safest multi-user mode (no credential storage)
return AuthMode.OAUTH_SINGLE_AUDIENCE
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
"""Validate configuration for detected mode.
Args:
settings: Application settings
Returns:
Tuple of (detected_mode, list_of_errors)
Empty list means valid configuration.
"""
mode = detect_auth_mode(settings)
requirements = MODE_REQUIREMENTS[mode]
errors: list[str] = []
logger.debug(f"Validating configuration for mode: {mode.value}")
# Check required variables
for var in requirements.required:
value = getattr(settings, var, None)
if value is None or (isinstance(value, str) and not value.strip()):
errors.append(
f"[{mode.value}] Missing required configuration: {var.upper()}"
)
# Check forbidden variables
for var in requirements.forbidden:
value = getattr(settings, var, None)
# For bools, check if True (forbidden means must be False/unset)
# For strings, check if non-empty
is_set = False
if isinstance(value, bool):
is_set = value is True
elif isinstance(value, str):
is_set = bool(value.strip())
elif value is not None:
is_set = True
if is_set:
errors.append(
f"[{mode.value}] Forbidden configuration: {var.upper()} "
f"should not be set in this mode"
)
# Check conditional requirements
for condition, required_vars in requirements.conditional.items():
# Check if the condition is enabled
condition_value = getattr(settings, condition, None)
is_enabled = False
if isinstance(condition_value, bool):
is_enabled = condition_value is True
elif isinstance(condition_value, str):
is_enabled = bool(condition_value.strip())
elif condition_value is not None:
is_enabled = True
if is_enabled:
# Check that all required vars for this condition are set
for var in required_vars:
value = getattr(settings, var, None)
# For boolean requirements, check that they are True (not just set)
if hasattr(Settings, var):
field_type = type(getattr(Settings(), var, None))
if field_type is bool:
if value is not True:
errors.append(
f"[{mode.value}] {var.upper()} must be enabled when "
f"{condition.upper()} is enabled"
)
continue
# For non-boolean requirements, check that they are set
if value is None or (isinstance(value, str) and not value.strip()):
errors.append(
f"[{mode.value}] {var.upper()} is required when "
f"{condition.upper()} is enabled"
)
# Special validations for specific modes
if mode == AuthMode.SINGLE_USER_BASIC:
# Validate that NEXTCLOUD_HOST doesn't have trailing slash
if settings.nextcloud_host and settings.nextcloud_host.endswith("/"):
errors.append(
f"[{mode.value}] NEXTCLOUD_HOST should not have trailing slash: "
f"{settings.nextcloud_host}"
)
if mode in [
AuthMode.OAUTH_SINGLE_AUDIENCE,
AuthMode.OAUTH_TOKEN_EXCHANGE,
]:
# If OAuth credentials not provided, DCR must be available
# (This is a runtime check, not a config check, so we just warn)
if not settings.oidc_client_id or not settings.oidc_client_secret:
logger.info(
f"[{mode.value}] OAuth credentials not configured. "
"Will attempt Dynamic Client Registration (DCR) at startup."
)
if mode == AuthMode.MULTI_USER_BASIC:
# If background operations enabled, check for OAuth credentials (for app password retrieval)
# Allow DCR as fallback, just like OAuth modes
if settings.enable_offline_access:
if not settings.oidc_client_id or not settings.oidc_client_secret:
logger.info(
f"[{mode.value}] OAuth credentials not configured. "
"Will attempt Dynamic Client Registration (DCR) at startup "
"(required for app password retrieval via Astrolabe)."
)
# Note: Vector sync no longer requires explicit ENABLE_OFFLINE_ACCESS setting
# ENABLE_SEMANTIC_SEARCH (formerly VECTOR_SYNC_ENABLED) automatically enables
# background operations in multi-user modes via smart dependency resolution
# in config.py
# Note: Embedding provider validation removed - Simple provider is always
# available as fallback (ADR-015). Users can optionally configure Ollama or OpenAI
# for better quality embeddings.
return mode, errors
def get_mode_summary(mode: AuthMode) -> str:
"""Get human-readable summary of a deployment mode.
Args:
mode: Deployment mode
Returns:
Multi-line string describing the mode
"""
requirements = MODE_REQUIREMENTS[mode]
summary_lines = [
f"Mode: {mode.value}",
f"Description: {requirements.description}",
"",
"Required configuration:",
]
if requirements.required:
for var in requirements.required:
summary_lines.append(f" - {var.upper()}")
else:
summary_lines.append(" (none - configured via session)")
summary_lines.append("")
summary_lines.append("Optional configuration:")
if requirements.optional:
for var in requirements.optional:
summary_lines.append(f" - {var.upper()}")
else:
summary_lines.append(" (none)")
if requirements.conditional:
summary_lines.append("")
summary_lines.append("Conditional requirements:")
for condition, vars in requirements.conditional.items():
summary_lines.append(f" When {condition.upper()} is enabled:")
for var in vars:
summary_lines.append(f" - {var.upper()}")
return "\n".join(summary_lines)
-69
View File
@@ -67,11 +67,6 @@ async def get_client(ctx: Context) -> NextcloudClient:
return _get_client_from_session_config(ctx)
settings = get_settings()
# Multi-user BasicAuth pass-through mode - extract credentials from request
if settings.enable_multi_user_basic_auth:
return _get_client_from_basic_auth(ctx)
lifespan_ctx = ctx.request_context.lifespan_context
# BasicAuth mode - use shared client (no token exchange)
@@ -182,67 +177,3 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
username=username,
auth=BasicAuth(username, app_password),
)
def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
"""
Create NextcloudClient from BasicAuth credentials in request headers.
For multi-user BasicAuth pass-through mode, this function extracts
username/password from the Authorization: Basic header (stored by
BasicAuthMiddleware) and creates a client that passes these credentials
through to Nextcloud APIs.
The credentials are NOT stored persistently - they exist only for the
duration of this request (stateless).
Args:
ctx: MCP request context with basic_auth in request state
Returns:
NextcloudClient configured with BasicAuth credentials
Raises:
ValueError: If BasicAuth credentials not found in request or if
NEXTCLOUD_HOST is not configured
"""
settings = get_settings()
# Validate that NEXTCLOUD_HOST is configured
if not settings.nextcloud_host:
raise ValueError(
"NEXTCLOUD_HOST environment variable must be set for multi-user BasicAuth mode"
)
# Extract BasicAuth credentials from request state (set by BasicAuthMiddleware)
# Access scope through the request object
scope = getattr(ctx.request_context.request, "scope", None)
if scope is None:
raise ValueError("Request scope not available in context")
request_state = scope.get("state", {})
basic_auth = request_state.get("basic_auth")
if not basic_auth:
raise ValueError(
"BasicAuth credentials not found in request. "
"Ensure Authorization: Basic header is provided with valid credentials."
)
username = basic_auth.get("username")
password = basic_auth.get("password")
if not username or not password:
raise ValueError("Invalid BasicAuth credentials - missing username or password")
logger.debug(
f"Creating multi-user BasicAuth client for {settings.nextcloud_host} as {username}"
)
# Create client that passes BasicAuth credentials through to Nextcloud
# settings.nextcloud_host is guaranteed to be str after the check above
return NextcloudClient(
base_url=settings.nextcloud_host,
username=username,
auth=BasicAuth(username, password),
)
+101 -59
View File
@@ -101,9 +101,6 @@ class ProvisioningStatus(BaseModel):
provisioned_at: Optional[str] = Field(
None, description="ISO timestamp when provisioned"
)
credential_type: Optional[str] = Field(
None, description="Type of credential ('refresh_token' or 'app_password')"
)
client_id: Optional[str] = Field(
None, description="Client ID that initiated the original Flow 1"
)
@@ -117,8 +114,8 @@ class ProvisioningResult(BaseModel):
"""Result of provisioning attempt."""
success: bool = Field(description="Whether provisioning was initiated")
provisioning_url: Optional[str] = Field(
None, description="URL to Astrolabe settings for provisioning background sync"
authorization_url: Optional[str] = Field(
None, description="URL for user to complete OAuth authorization"
)
message: str = Field(description="Status message for the user")
already_provisioned: bool = Field(
@@ -146,9 +143,8 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
"""
Check the provisioning status for Nextcloud access.
Checks for both credential types:
1. App password from Astrolabe (works today)
2. OAuth refresh token from storage (for future)
This checks whether the user has completed Flow 2 to provision
offline access to Nextcloud resources.
Args:
mcp: MCP context
@@ -157,37 +153,6 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
Returns:
ProvisioningStatus with current provisioning state
"""
from datetime import datetime, timezone
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
# Check for app password first (interim solution)
if settings.oidc_client_id and settings.oidc_client_secret:
try:
astrolabe = AstrolabeClient(
nextcloud_host=settings.nextcloud_host or "",
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
)
status = await astrolabe.get_background_sync_status(user_id)
if status.get("has_access"):
logger.info(
f" get_provisioning_status: ✓ App password FOUND for user_id={user_id}"
)
provisioned_at_str = status.get("provisioned_at")
return ProvisioningStatus(
is_provisioned=True,
provisioned_at=provisioned_at_str,
credential_type="app_password",
)
except Exception as e:
logger.debug(f" App password check failed for {user_id}: {e}")
# Check for OAuth refresh token (fallback)
logger.info(
f" get_provisioning_status: Looking up refresh token for user_id={user_id}"
)
@@ -198,7 +163,7 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
if not token_data:
logger.info(
f" get_provisioning_status: ✗ No credentials found for user_id={user_id}"
f" get_provisioning_status: ✗ No refresh token found for user_id={user_id}"
)
return ProvisioningStatus(is_provisioned=False)
@@ -213,13 +178,14 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
# Convert timestamp to ISO format if present
provisioned_at_str = None
if token_data.get("provisioned_at"):
from datetime import datetime, timezone
dt = datetime.fromtimestamp(token_data["provisioned_at"], tz=timezone.utc)
provisioned_at_str = dt.isoformat()
return ProvisioningStatus(
is_provisioned=True,
provisioned_at=provisioned_at_str,
credential_type="refresh_token",
client_id=token_data.get("provisioning_client_id"),
scopes=token_data.get("scopes"),
flow_type=token_data.get("flow_type", "hybrid"),
@@ -273,22 +239,36 @@ async def provision_nextcloud_access(
"""
MCP Tool: Provision offline access to Nextcloud resources.
Returns URL to Astrolabe settings page where users can provision background
sync access using either:
- App password (works today, interim solution)
- OAuth refresh token (future, when Nextcloud supports OAuth for app APIs)
This tool initiates Flow 2 of the Progressive Consent architecture,
allowing the MCP server to obtain delegated access to Nextcloud APIs.
The user must complete the OAuth flow in their browser to grant access.
Args:
ctx: MCP context with user's Flow 1 token
user_id: Optional user identifier (extracted from token if not provided)
Returns:
ProvisioningResult with Astrolabe settings URL or status
ProvisioningResult with authorization URL or status
"""
try:
# Extract user ID from the MCP access token (Flow 1 token)
if not user_id:
user_id = await extract_user_id_from_token(ctx)
# Get the authorization token from context
if hasattr(ctx, "authorization") and ctx.authorization:
token = ctx.authorization.token # type: ignore
# Decode token to get user info
try:
import jwt
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub", "unknown")
logger.info(f"Extracted user_id from Flow 1 token: {user_id}")
except Exception as e:
logger.warning(f"Failed to decode token: {e}")
user_id = "default_user"
else:
user_id = "default_user"
# Check if already provisioned
status = await get_provisioning_status(ctx, user_id)
@@ -297,8 +277,7 @@ async def provision_nextcloud_access(
success=True,
already_provisioned=True,
message=(
f"Nextcloud access is already provisioned (credential_type={status.credential_type}, "
f"since {status.provisioned_at}). "
f"Nextcloud access is already provisioned (since {status.provisioned_at}). "
"Use 'revoke_nextcloud_access' if you want to re-provision."
),
)
@@ -316,20 +295,83 @@ async def provision_nextcloud_access(
),
)
# Return Astrolabe settings URL for background sync provisioning
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
astrolabe_url = f"{nextcloud_host}/settings/user/astrolabe#background-sync"
# Get MCP server's OAuth client credentials
# Try environment variable first, then fall back to DCR client_id
server_client_id = os.getenv("MCP_SERVER_CLIENT_ID")
if not server_client_id:
# Try to get from lifespan context (DCR)
lifespan_ctx = ctx.request_context.lifespan_context
if hasattr(lifespan_ctx, "server_client_id"):
server_client_id = lifespan_ctx.server_client_id
if not server_client_id:
return ProvisioningResult(
success=False,
message=(
"MCP server OAuth client not configured. "
"Set MCP_SERVER_CLIENT_ID environment variable or use Dynamic Client Registration."
),
)
# Generate OAuth URL for Flow 2
oidc_discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
)
# Generate secure state for CSRF protection
state = secrets.token_urlsafe(32)
# Store state in session for validation on callback
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Create OAuth session for Flow 2
session_id = f"flow2_{user_id}_{secrets.token_hex(8)}"
redirect_uri = f"{os.getenv('NEXTCLOUD_MCP_SERVER_URL', 'http://localhost:8000')}/oauth/callback"
await storage.store_oauth_session(
session_id=session_id,
client_redirect_uri="", # No client redirect for Flow 2
state=state,
flow_type="flow2",
is_provisioning=True,
ttl_seconds=600, # 10 minute TTL
)
# Define scopes for Nextcloud access
scopes = [
"openid",
"profile",
"email",
"offline_access", # Critical for background operations
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"contacts:read",
"contacts:write",
"files:read",
"files:write",
]
# Generate authorization URL
auth_url = generate_oauth_url_for_flow2(
oidc_discovery_url=oidc_discovery_url,
server_client_id=server_client_id,
redirect_uri=redirect_uri,
state=state,
scopes=scopes,
)
return ProvisioningResult(
success=True,
provisioning_url=astrolabe_url,
authorization_url=auth_url,
message=(
"Visit Astrolabe settings to provision background sync access.\n\n"
"You can choose either:\n"
"- App password (works today, recommended for now)\n"
"- OAuth refresh token (future, when Nextcloud fully supports OAuth)\n\n"
"After provisioning, background sync will enable the MCP server to "
"access Nextcloud resources even when you're not actively connected."
"Please visit the authorization URL to grant the MCP server "
"offline access to your Nextcloud resources. This is a one-time "
"setup that allows the server to access Nextcloud on your behalf "
"even when you're not actively connected."
),
)
-40
View File
@@ -5,10 +5,6 @@ with ENABLE_OFFLINE_ACCESS=true:
- User Manager: Monitors RefreshTokenStorage for user changes
- Per-User Scanners: One scanner task per provisioned user
- Shared Processor Pool: Processes documents from all users
Supports dual credential types for background sync:
- App passwords (interim solution, works today)
- OAuth refresh tokens (future, when Nextcloud supports OAuth for app APIs)
"""
import logging
@@ -22,9 +18,7 @@ from anyio.streams.memory import (
MemoryObjectReceiveStream,
MemoryObjectSendStream,
)
from httpx import BasicAuth
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
@@ -66,10 +60,6 @@ async def get_user_client(
) -> NextcloudClient:
"""Get an authenticated NextcloudClient for a user.
Supports dual credential types with priority:
1. App password from Astrolabe (works today with BasicAuth)
2. OAuth refresh token from storage (for future when OAuth fully supported)
Args:
user_id: User identifier
token_broker: Token broker for obtaining access tokens
@@ -81,36 +71,6 @@ async def get_user_client(
Raises:
NotProvisionedError: If user has not provisioned offline access
"""
settings = get_settings()
# Try app password first (interim solution, works today)
if settings.oidc_client_id and settings.oidc_client_secret:
try:
astrolabe = AstrolabeClient(
nextcloud_host=nextcloud_host,
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
)
app_password = await astrolabe.get_user_app_password(user_id)
if app_password:
logger.info(
f"Using app password for background sync: {user_id} "
f"(credential_type=app_password)"
)
return NextcloudClient(
base_url=nextcloud_host,
username=user_id,
auth=BasicAuth(user_id, app_password),
)
except Exception as e:
logger.debug(f"App password not available for {user_id}: {e}")
# Fall back to OAuth refresh token
logger.info(
f"Using OAuth refresh token for background sync: {user_id} "
f"(credential_type=refresh_token)"
)
token = await token_broker.get_background_token(user_id, VECTOR_SYNC_SCOPES)
if not token:
raise NotProvisionedError(f"User {user_id} has not provisioned offline access")
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.59.0"
version = "0.56.2"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"}
-9
View File
@@ -58,15 +58,6 @@ fi
# Run commitizen bump and capture output
if ! output=$($CZ_CMD 2>&1); then
cd ../..
# Check if this is the expected "no commits to bump" case
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
echo "️ No commits eligible for version bump" >&2
echo "$output" >&2
exit 0
fi
# Otherwise, this is an actual error
echo "❌ Error: Version bump failed" >&2
echo "$output" >&2
echo "" >&2
-9
View File
@@ -53,15 +53,6 @@ fi
# Run commitizen bump and capture output
if ! output=$($CZ_CMD 2>&1); then
cd ../..
# Check if this is the expected "no commits to bump" case
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
echo "️ No commits eligible for version bump" >&2
echo "$output" >&2
exit 0
fi
# Otherwise, this is an actual error
echo "❌ Error: Version bump failed" >&2
echo "$output" >&2
echo "" >&2
-8
View File
@@ -44,14 +44,6 @@ fi
# Run commitizen bump and capture output
if ! output=$($CZ_CMD 2>&1); then
# Check if this is the expected "no commits to bump" case
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
echo "️ No commits eligible for version bump" >&2
echo "$output" >&2
exit 0
fi
# Otherwise, this is an actual error
echo "❌ Error: Version bump failed" >&2
echo "$output" >&2
echo "" >&2
+3 -273
View File
@@ -114,7 +114,6 @@ async def create_mcp_client_session(
client_name: str = "MCP",
elicitation_callback: Any = None,
sampling_callback: Any = None,
headers: dict[str, str] | None = None,
) -> AsyncGenerator[ClientSession, Any]:
"""
Factory function to create an MCP client session with proper lifecycle management.
@@ -136,8 +135,6 @@ async def create_mcp_client_session(
Should match signature: async def callback(context: RequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData
sampling_callback: Optional callback for handling sampling (LLM generation) requests.
Should match signature: async def callback(context: RequestContext, params: CreateMessageRequestParams) -> CreateMessageResult | ErrorData
headers: Optional custom headers (e.g., for BasicAuth). If both headers and token are provided,
custom headers take precedence.
Yields:
Initialized MCP ClientSession
@@ -150,9 +147,8 @@ async def create_mcp_client_session(
"""
logger.info(f"Creating Streamable HTTP client for {client_name}")
# Prepare headers - custom headers take precedence over token-based auth
if headers is None:
headers = {"Authorization": f"Bearer {token}"} if token else None
# Prepare headers with OAuth token if provided
headers = {"Authorization": f"Bearer {token}"} if token else None
# Use native async with - Python ensures LIFO cleanup
# Cleanup order will be: ClientSession.__aexit__ -> streamablehttp_client.__aexit__
@@ -244,32 +240,6 @@ async def nc_mcp_oauth_client(
yield session
@pytest.fixture(scope="session")
async def nc_mcp_basic_auth_client(
anyio_backend,
) -> AsyncGenerator[ClientSession, Any]:
"""
Fixture to create an MCP client session with BasicAuth credentials.
Connects to the multi-user BasicAuth MCP server on port 8003 with ENABLE_MULTI_USER_BASIC_AUTH=true.
Uses BasicAuth credentials for multi-user pass-through mode (ADR-020).
Credentials are passed in Authorization header and forwarded to Nextcloud APIs.
Uses anyio pytest plugin for proper async fixture handling.
"""
import base64
credentials = base64.b64encode(b"admin:admin").decode("utf-8")
auth_header = f"Basic {credentials}"
async for session in create_mcp_client_session(
url="http://localhost:8003/mcp",
headers={"Authorization": auth_header},
client_name="BasicAuth MCP (Multi-User)",
):
yield session
@pytest.fixture(scope="session")
async def nc_mcp_oauth_jwt_client(
anyio_backend,
@@ -2320,10 +2290,7 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
},
}
logger.info("=" * 60)
logger.info("EXECUTING test_users_setup FIXTURE (session-scoped)")
logger.info(f"Creating test users: {list(test_user_configs.keys())}")
logger.info("=" * 60)
logger.info("Creating test users for multi-user OAuth testing...")
created_users = []
try:
@@ -3220,240 +3187,3 @@ async def nc_mcp_keycloak_client_no_custom_scopes(
client_name="Keycloak No Custom Scopes MCP",
):
yield session
# ========================================================================
# Astrolabe Dynamic Configuration Fixtures
# ========================================================================
@pytest.fixture(scope="session")
async def configure_astrolabe_for_mcp_server(nc_client):
"""Configure Astrolabe app to connect to a specific MCP server.
This fixture dynamically configures the Astrolabe app's MCP server settings
and OAuth client, allowing tests to verify integration with different MCP
server deployments (mcp-oauth, mcp-keycloak, mcp-multi-user-basic, etc.).
Usage:
async def test_my_integration(configure_astrolabe_for_mcp_server):
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-oauth:8001",
mcp_server_public_url="http://localhost:8001"
)
# ... test Astrolabe integration ...
Args:
nc_client: NextcloudClient fixture for occ command execution
Returns:
Async function that accepts:
- mcp_server_internal_url: Internal Docker URL for PHP app to call MCP APIs
- mcp_server_public_url: Public URL for OAuth token audience validation
- client_id: Optional OAuth client ID (default: "nextcloudMcpServerUIPublicClient")
"""
import json
import subprocess
async def _configure(
mcp_server_internal_url: str,
mcp_server_public_url: str,
client_id: str = "nextcloudMcpServerUIPublicClient",
) -> dict[str, str]:
"""Configure Astrolabe for the specified MCP server.
Returns:
Dict with client_id and client_secret
"""
logger.info(
f"Configuring Astrolabe for MCP server: {mcp_server_internal_url} (public: {mcp_server_public_url})"
)
# Configure MCP server URLs in Nextcloud system config
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"app",
"php",
"/var/www/html/occ",
"config:system:set",
"mcp_server_url",
"--value",
mcp_server_internal_url,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"Failed to configure MCP server URL. "
f"Command failed with code {result.returncode}. "
f"stderr: {result.stderr}, stdout: {result.stdout}"
)
# Verify mcp_server_url was actually set
verify_result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"app",
"php",
"/var/www/html/occ",
"config:system:get",
"mcp_server_url",
],
capture_output=True,
text=True,
)
actual_url = verify_result.stdout.strip()
if actual_url != mcp_server_internal_url:
raise RuntimeError(
f"MCP server URL verification failed. "
f"Expected: {mcp_server_internal_url}, Got: {actual_url}"
)
logger.info(f"✓ MCP server URL configured and verified: {actual_url}")
# Configure public URL
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"app",
"php",
"/var/www/html/occ",
"config:system:set",
"mcp_server_public_url",
"--value",
mcp_server_public_url,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"Failed to configure MCP server public URL. "
f"Command failed with code {result.returncode}. "
f"stderr: {result.stderr}, stdout: {result.stdout}"
)
logger.info(f"✓ MCP server public URL configured: {mcp_server_public_url}")
# Remove existing OAuth client if it exists
try:
subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"app",
"php",
"/var/www/html/occ",
"oidc:remove",
client_id,
],
check=False, # Don't fail if client doesn't exist
capture_output=True,
)
logger.info(f"Removed existing OAuth client: {client_id}")
except Exception:
pass
# Create OAuth client for Astrolabe
redirect_uri = "http://localhost:8080/apps/astrolabe/oauth/callback"
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"app",
"php",
"/var/www/html/occ",
"oidc:create",
"Astrolabe",
redirect_uri,
"--client_id",
client_id,
"--type",
"confidential",
"--flow",
"code",
"--token_type",
"jwt",
"--resource_url",
mcp_server_public_url,
"--allowed_scopes",
"openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write",
],
check=True,
capture_output=True,
text=True,
)
# Parse client_secret from JSON output
client_output = json.loads(result.stdout.strip())
client_secret = client_output.get("client_secret")
if not client_secret:
raise ValueError(
"Failed to extract client_secret from OAuth client creation"
)
logger.info(f"✓ OAuth client created: {client_id}")
# Store client credentials in Nextcloud system config
subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"app",
"php",
"/var/www/html/occ",
"config:system:set",
"astrolabe_client_id",
"--value",
client_id,
],
check=True,
capture_output=True,
)
subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"app",
"php",
"/var/www/html/occ",
"config:system:set",
"astrolabe_client_secret",
"--value",
client_secret,
],
check=True,
capture_output=True,
)
logger.info("✓ Client credentials stored in system config")
logger.info(f"Astrolabe configured for MCP server: {mcp_server_public_url}")
return {"client_id": client_id, "client_secret": client_secret}
return _configure
@@ -1,151 +0,0 @@
"""Integration tests for app password provisioning via Astrolabe.
Tests the complete flow:
1. User stores app password via Astrolabe API
2. MCP server retrieves it via OAuth client credentials
3. Background sync uses it to access Nextcloud
"""
import pytest
from httpx import BasicAuth
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.oauth_sync import get_user_client
@pytest.mark.integration
async def test_astrolabe_client_initialization():
"""Test AstrolabeClient can be instantiated."""
client = AstrolabeClient(
nextcloud_host="http://localhost:8080",
client_id="test-client",
client_secret="test-secret",
)
assert client is not None
assert client.nextcloud_host == "http://localhost:8080"
assert client.client_id == "test-client"
assert client.client_secret == "test-secret"
assert client._token_cache is None
@pytest.mark.integration
async def test_astrolabe_client_get_access_token_requires_oidc():
"""Test that getting access token requires OIDC discovery endpoint."""
client = AstrolabeClient(
nextcloud_host="http://localhost:8080",
client_id="test-client",
client_secret="test-secret",
)
# This will fail without proper OIDC setup, which is expected
# The test verifies the client follows the OAuth client credentials flow
try:
token = await client.get_access_token()
# If we get here, OIDC is configured
assert token is not None
except Exception as e:
# Expected if OIDC not fully configured for test client
# 400/401/403/404 all indicate the flow is working but credentials are invalid
assert any(code in str(e) for code in ["400", "401", "403", "404"])
@pytest.mark.integration
async def test_get_user_app_password_returns_none_for_unconfigured_user():
"""Test that get_user_app_password returns None for users without app passwords."""
# This requires valid OAuth client credentials
settings = get_settings()
if not settings.oidc_client_id or not settings.oidc_client_secret:
pytest.skip("OAuth client credentials not configured")
client = AstrolabeClient(
nextcloud_host=settings.nextcloud_host or "http://localhost:8080",
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
)
# Try to get app password for a user that hasn't provisioned one
try:
app_password = await client.get_user_app_password("nonexistent_user")
# Should return None for unconfigured user (404 response)
assert app_password is None
except Exception as e:
# May fail with auth error if OAuth not fully configured
assert any(code in str(e) for code in ["400", "401", "403", "404"])
@pytest.mark.integration
async def test_dual_credential_support_in_background_sync(mocker):
"""Test that background sync tries app password first, then refresh token."""
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
# Mock AstrolabeClient to return an app password
mock_astrolabe = mocker.AsyncMock()
mock_astrolabe.get_user_app_password.return_value = "test-app-password-12345"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
return_value=mock_astrolabe,
)
# Mock TokenBrokerService (shouldn't be called if app password works)
mock_token_broker = mocker.MagicMock(spec=TokenBrokerService)
# Call get_user_client - should use app password
try:
_client = await get_user_client(
user_id="test_user",
token_broker=mock_token_broker,
nextcloud_host="http://localhost:8080",
)
# Verify app password was requested
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
# Verify token broker was NOT called (app password took priority)
mock_token_broker.get_background_token.assert_not_called()
# Verify client uses BasicAuth
assert _client.auth is not None
assert isinstance(_client.auth, BasicAuth)
except Exception:
# May fail in test environment, but we verified the priority logic
pass
@pytest.mark.integration
async def test_background_sync_falls_back_to_refresh_token(mocker):
"""Test that background sync falls back to refresh token if no app password."""
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
# Mock AstrolabeClient to return None (no app password)
mock_astrolabe = mocker.AsyncMock()
mock_astrolabe.get_user_app_password.return_value = None
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
return_value=mock_astrolabe,
)
# Mock TokenBrokerService to return an access token
mock_token_broker = mocker.AsyncMock(spec=TokenBrokerService)
mock_token_broker.get_background_token.return_value = "test-access-token"
# Call get_user_client - should fall back to refresh token
try:
_client = await get_user_client(
user_id="test_user",
token_broker=mock_token_broker,
nextcloud_host="http://localhost:8080",
)
# Verify app password was attempted first
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
# Verify token broker was called as fallback
mock_token_broker.get_background_token.assert_called_once()
except Exception:
# May fail in test environment, but we verified the fallback logic
pass
@@ -1,561 +0,0 @@
"""Integration test for multi-user Astrolabe background sync enablement.
This test verifies that multiple users can independently:
1. Log in to Nextcloud
2. Generate an app password in Security settings
3. Enter the app password in Astrolabe personal settings
4. Enable background sync for the mcp-multi-user-basic service
5. Verify app password is stored in the database
Tests the complete app password provisioning flow:
user login Security settings app password generation Astrolabe settings
app password entry background sync activation database verification.
"""
import logging
import anyio
import pytest
from playwright.async_api import Page
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def login_to_nextcloud(page: Page, username: str, password: str):
"""Helper function to login to Nextcloud via Playwright.
Args:
page: Playwright page instance
username: Nextcloud username
password: Nextcloud password
"""
nextcloud_url = "http://localhost:8080"
logger.info(f"Logging in to Nextcloud as {username}...")
await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
# Fill in login form
await page.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
# Submit form
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=30000)
# Verify logged in (should redirect away from login page)
current_url = page.url
assert "/login" not in current_url, (
f"Login failed for {username}, still on login page"
)
logger.info(f"✓ Successfully logged in as {username}")
async def navigate_to_astrolabe_settings(page: Page):
"""Navigate to Astrolabe personal settings page.
Args:
page: Playwright page instance (must be authenticated)
"""
nextcloud_url = "http://localhost:8080"
settings_url = f"{nextcloud_url}/settings/user/astrolabe"
logger.info(f"Navigating to Astrolabe settings: {settings_url}")
await page.goto(settings_url, wait_until="networkidle", timeout=30000)
# Verify we're on the settings page
current_url = page.url
assert "/settings/user/astrolabe" in current_url, (
f"Failed to navigate to Astrolabe settings, current URL: {current_url}"
)
logger.info("✓ Successfully loaded Astrolabe settings page")
async def generate_app_password(
page: Page, username: str, app_name: str = "Astrolabe Background Sync"
) -> str:
"""Generate an app password in Nextcloud Security settings.
Args:
page: Playwright page instance (must be authenticated)
username: Username (for logging)
app_name: Name for the app password
Returns:
The generated app password string
"""
logger.info(f"Generating app password for {username}...")
nextcloud_url = "http://localhost:8080"
# Navigate to Security settings
await page.goto(f"{nextcloud_url}/settings/user/security", wait_until="networkidle")
logger.info("Navigated to Security settings")
# Fill the app password input field (selector confirmed via Playwright MCP)
app_password_input = page.locator('input[placeholder="App name"]')
await app_password_input.fill(app_name)
logger.info(f"Entered app name: {app_name}")
# Wait for Vue.js to react and enable the button (needs 1 second, not 0.5)
await anyio.sleep(1.0)
logger.info("Waited for Vue.js to process input and enable button")
# Click the create button
create_button = page.locator(
'button[type="submit"]:has-text("Create new app password")'
)
await create_button.click()
logger.info("Clicked create app password button")
# Wait for app password to be generated and displayed in the dialog
await anyio.sleep(3) # Give it more time to generate and display
# Find the Login input field which should have the username value
# Then find the Password input field which is in the same form
app_password = None
try:
# Wait for heading "New app password" to appear
await page.wait_for_selector('text="New app password"', timeout=10000)
logger.info("App password dialog appeared with heading")
# Get all visible input elements
all_inputs = await page.locator('input[type="text"]').all()
logger.info(f"Found {len(all_inputs)} text input elements")
# Check each input to find the one with the app password
for idx, input_elem in enumerate(all_inputs):
try:
value = await input_elem.input_value()
if value and "-" in value and len(value) > 20:
app_password = value.strip()
logger.info(
f"Found app password in input {idx}: '{app_password}' (length: {len(app_password)})"
)
break
except Exception as e:
logger.debug(f"Could not get value from input {idx}: {e}")
continue
except Exception as e:
logger.error(f"Failed to find app password dialog or extract password: {e}")
if not app_password:
# Take screenshot for debugging
screenshot_path = f"/tmp/app_password_generation_{username}.png"
await page.screenshot(path=screenshot_path)
raise ValueError(
f"Could not find generated app password. Screenshot: {screenshot_path}"
)
# Validate password format before returning
import re
if not re.match(
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$",
app_password,
):
logger.error(
f"Extracted password does not match expected format: '{app_password}'"
)
logger.error(f"Password repr: {repr(app_password)}")
screenshot_path = f"/tmp/app_password_invalid_format_{username}.png"
await page.screenshot(path=screenshot_path)
raise ValueError(
f"App password format validation failed. Screenshot: {screenshot_path}"
)
logger.info(
f"✓ Generated app password for {username}: {app_password[:10]}... (validated)"
)
# Close the dialog by clicking the Close button
close_button = page.get_by_role("button", name="Close")
await close_button.click()
logger.info("Closed app password dialog")
await anyio.sleep(0.5)
return app_password
async def enable_background_sync_via_app_password(
page: Page, username: str, app_password: str
):
"""Enable background sync by entering app password in Astrolabe settings.
Args:
page: Playwright page instance
username: Username (for logging)
app_password: App password to enter
Returns:
True if background sync was enabled successfully
"""
logger.info(f"Enabling background sync via app password for {username}...")
nextcloud_url = "http://localhost:8080"
# Set up network request and console listeners BEFORE navigation
network_requests = []
network_responses = []
console_messages = []
def log_request(req):
network_requests.append(f"{req.method} {req.url}")
def log_response(resp):
response_info = f"{resp.status} {resp.url}"
network_responses.append(response_info)
logger.info(f"Response: {response_info}")
def log_console(msg):
console_messages.append(f"[{msg.type}] {msg.text}")
page.on("request", log_request)
page.on("response", log_response)
page.on("console", log_console)
# Navigate to Astrolabe settings
await page.goto(
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
)
# Wait for page to load
await anyio.sleep(1)
# Check if already active (look for "Active" text in the Background Sync Access section)
try:
# The "Active" badge appears as a <span> with text "Active"
active_text = page.get_by_text("Active", exact=True)
if await active_text.is_visible(timeout=2000):
logger.info(f"✓ Background sync already active for {username}")
return True
except Exception:
pass
# Find the app password input field using the placeholder text
# Based on manual testing: textbox with placeholder "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
try:
await app_password_input.wait_for(timeout=5000, state="visible")
logger.info("Found app password input field")
except Exception:
# Take screenshot for debugging
screenshot_path = f"/tmp/astrolabe_no_password_field_{username}.png"
await page.screenshot(path=screenshot_path)
raise ValueError(
f"Could not find app password input field for {username}. Screenshot: {screenshot_path}"
)
# Enter the app password
await app_password_input.fill(app_password)
logger.info(f"Entered app password for {username}")
# Wait a moment for any validation to complete
await anyio.sleep(0.5)
# Take screenshot before clicking Save to check for warnings
screenshot_path = f"/tmp/before_save_{username}.png"
await page.screenshot(path=screenshot_path)
logger.info(f"Screenshot taken before Save: {screenshot_path}")
# Find and click the Save button
save_button = page.get_by_role("button", name="Save")
# Check if Save button is enabled
is_disabled = await save_button.is_disabled()
logger.info(f"Save button disabled state: {is_disabled}")
await save_button.click()
logger.info("Clicked Save button")
# Give the request time to complete before checking logs
await anyio.sleep(0.5)
# Log network requests after clicking Save
logger.info(f"Network requests after Save for {username}:")
for req in network_requests[-10:]: # Last 10 requests
logger.info(f" {req}")
# Log network responses after clicking Save
logger.info(f"Network responses after Save for {username}:")
for resp in network_responses[-10:]: # Last 10 responses
logger.info(f" {resp}")
# Check specifically for the credentials POST response
credentials_responses = [
r for r in network_responses if "background-sync/credentials" in r
]
if credentials_responses:
logger.info(f"Credentials endpoint response: {credentials_responses[-1]}")
if "200" not in credentials_responses[-1]:
logger.error(
f"Credentials POST did not return 200 OK: {credentials_responses[-1]}"
)
else:
logger.warning("No response found for credentials endpoint!")
# Wait for the page to reload after successful save
# The JavaScript in personalSettings.js does: setTimeout(() => window.location.reload(), 1000)
await page.wait_for_load_state("networkidle", timeout=15000)
await anyio.sleep(2)
# Log any console messages
if console_messages:
logger.info(f"Console messages for {username}:")
for msg in console_messages:
logger.info(f" {msg}")
# Check for error notifications (toast messages)
try:
error_toast = page.locator(".toastify.toast-error, .toast-error")
if await error_toast.count() > 0:
error_text = await error_toast.first.text_content()
logger.error(f"Error notification for {username}: {error_text}")
except Exception:
pass
# Verify "Active" text appears after reload
try:
active_text = page.get_by_text("Active", exact=True)
await active_text.wait_for(timeout=5000, state="visible")
logger.info(f"✓ Background sync enabled for {username} - Active badge visible")
return True
except Exception:
# Take screenshot for debugging
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
await page.screenshot(path=screenshot_path)
logger.error(
f"Active badge did not appear for {username}. Screenshot: {screenshot_path}"
)
raise
async def verify_app_password_created(username: str) -> bool:
"""Verify that background sync app password was stored for the user.
This checks the Nextcloud database for background sync credentials stored
by Astrolabe in the oc_preferences table.
Args:
username: Nextcloud username
Returns:
True if background sync app password exists
"""
logger.info(f"Verifying background sync app password for {username}...")
# Query the database to check for background sync credentials
# Astrolabe stores app passwords in oc_preferences, not oc_authtoken
import subprocess
query = f"""
SELECT userid, configkey, configvalue
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey IN ('background_sync_password', 'background_sync_type', 'background_sync_provisioned_at')
ORDER BY configkey;
"""
try:
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
output = result.stdout
logger.debug(f"Background sync credentials query result:\n{output}")
# Check if background sync credentials exist
# We should see 3 rows: background_sync_password, background_sync_type, background_sync_provisioned_at
lines = output.strip().split("\n")
if len(lines) >= 3: # Header + at least 2 data rows (password + type)
# Verify background_sync_type is "app_password"
if "app_password" in output:
logger.info(f"✓ Background sync app password stored for {username}")
return True
else:
logger.warning(
f"Background sync credentials found but type is not app_password for {username}"
)
return False
else:
logger.warning(f"No background sync credentials found for {username}")
return False
except Exception as e:
logger.error(f"Error checking background sync credentials for {username}: {e}")
return False
@pytest.mark.integration
@pytest.mark.oauth
async def test_multi_user_astrolabe_background_sync_enablement(
browser,
nc_client,
test_users_setup,
configure_astrolabe_for_mcp_server,
):
"""Test that multiple users can independently enable background sync via app passwords.
This test verifies the complete app password provisioning flow:
1. Users log in to Nextcloud
2. Users generate app passwords in Security settings
3. Users navigate to Astrolabe personal settings
4. Users enter their app passwords in the Astrolabe form
5. Background sync becomes active with "Active" badge
6. App passwords are stored in the database (oc_authtoken table)
7. The process works correctly for multiple users
Requirements:
- Astrolabe app installed in Nextcloud and configured for mcp-multi-user-basic
- MCP server running in multi-user BasicAuth mode (mcp-multi-user-basic service)
- Test users (alice, bob) created with valid credentials
This tests ADR-002 Tier 2 authentication: User-specific app passwords for background operations
in multi-user BasicAuth deployments.
"""
# Configure Astrolabe to point to the mcp-multi-user-basic server
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
mcp_server_public_url="http://localhost:8003",
)
# Test users to check
test_users = ["alice", "bob"]
# Verify test users were created by the fixture
logger.info("Verifying test users exist in Nextcloud...")
for username in test_users:
try:
# Use nc_client to check if user exists
user_details = await nc_client.users.get_user_details(username)
logger.info(
f"✓ Confirmed {username} exists (display name: {user_details.displayname})"
)
except Exception as e:
raise AssertionError(
f"Test user {username} does not exist! "
f"test_users_setup fixture may have failed. Error: {e}"
)
results = {}
for username in test_users:
logger.info(f"\n{'=' * 60}")
logger.info(f"Testing background sync enablement for: {username}")
logger.info(f"{'=' * 60}")
user_config = test_users_setup[username]
password = user_config["password"]
# Create new browser context for this user
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
# Step 1: Login to Nextcloud
await login_to_nextcloud(page, username, password)
# Step 2: Generate app password in Security settings
app_password = await generate_app_password(page, username)
# Step 3: Enable background sync by entering app password in Astrolabe
sync_enabled = await enable_background_sync_via_app_password(
page, username, app_password
)
# Step 4: Verify app password was stored in database
app_password_stored = await verify_app_password_created(username)
# Give it time to complete
await anyio.sleep(1)
results[username] = {
"settings_accessed": True,
"app_password_generated": bool(app_password),
"sync_enabled": sync_enabled,
"app_password_stored": app_password_stored,
"background_sync_active": sync_enabled and app_password_stored,
}
logger.info(f"\n{username} results:")
logger.info(" Settings accessed: ✓")
logger.info(f" App password generated: {'' if app_password else ''}")
logger.info(f" Sync enabled: {'' if sync_enabled else ''}")
logger.info(f" App password stored: {'' if app_password_stored else ''}")
logger.info(
f" Background sync active: {'' if (sync_enabled and app_password_stored) else ''}"
)
except Exception as e:
logger.error(f"Error during {username} test: {e}")
results[username] = {
"settings_accessed": False,
"app_password_generated": False,
"sync_enabled": False,
"app_password_stored": False,
"background_sync_active": False,
"error": str(e),
}
finally:
await context.close()
# Verify all users succeeded
logger.info(f"\n{'=' * 60}")
logger.info("Test Summary")
logger.info(f"{'=' * 60}")
for username, result in results.items():
logger.info(f"\n{username}:")
for key, value in result.items():
if key != "error":
status = "" if value else ""
logger.info(f" {key}: {status}")
elif value:
logger.info(f" error: {value}")
# Assert all users successfully enabled background sync
for username in test_users:
result = results[username]
assert result["settings_accessed"], (
f"{username} could not access Astrolabe settings"
)
assert result["app_password_generated"], (
f"{username} app password was not generated"
)
assert result["sync_enabled"], (
f"{username} background sync enablement did not complete successfully"
)
assert result["app_password_stored"], (
f"{username} app password was not stored in database"
)
assert result["background_sync_active"], (
f"{username} background sync is not active"
)
logger.info(
f"\n✓ All {len(test_users)} users successfully enabled background sync via app passwords!"
)
@@ -1,91 +0,0 @@
"""Integration tests for Astrolabe personal settings page buttons.
Tests the button functionality on /settings/user/astrolabe:
1. Disable Indexing button (POST to /apps/astrolabe/api/revoke)
2. Disconnect button (POST to /apps/astrolabe/oauth/disconnect)
These tests verify that:
- The endpoints respond correctly to POST requests
- CSRF token validation works
- User actions are properly handled
- Appropriate redirects occur
"""
import httpx
import pytest
@pytest.mark.integration
async def test_disable_indexing_button_endpoint_exists():
"""Test that the Disable Indexing endpoint is accessible."""
async with httpx.AsyncClient() as client:
# Try without authentication - should return 401 or redirect
response = await client.post(
"http://localhost:8080/apps/astrolabe/api/revoke",
follow_redirects=False,
)
# Should get 401 Unauthorized or 30x redirect
assert response.status_code in [401, 301, 302, 303, 307, 308], (
f"Expected 401 or redirect without auth, got {response.status_code}"
)
@pytest.mark.integration
async def test_disconnect_button_endpoint_exists():
"""Test that the Disconnect endpoint is accessible."""
async with httpx.AsyncClient() as client:
# Try without authentication - should return 401 or redirect
response = await client.post(
"http://localhost:8080/apps/astrolabe/oauth/disconnect",
follow_redirects=False,
)
# Should get 401 Unauthorized or 30x redirect
assert response.status_code in [401, 301, 302, 303, 307, 308], (
f"Expected 401 or redirect without auth, got {response.status_code}"
)
@pytest.mark.integration
async def test_settings_page_renders_buttons():
"""Test that the settings page template includes button forms.
This test verifies that the PHP template renders the form elements.
It doesn't require authentication since we're just checking the route exists.
"""
async with httpx.AsyncClient(follow_redirects=False) as client:
# Try to access settings page
response = await client.get("http://localhost:8080/settings/user/astrolabe")
# Should get 401/redirect if not authenticated (expected)
# or 200 if user session exists from browser testing
assert response.status_code in [200, 401, 302, 303, 307, 308], (
f"Unexpected status code: {response.status_code}"
)
@pytest.mark.integration
@pytest.mark.skip(
reason="Requires manual authentication - test with Playwright instead"
)
async def test_disconnect_button_functionality():
"""Test that clicking Disconnect button clears user OAuth tokens.
NOTE: This test is skipped because programmatic login to Nextcloud is complex.
Use Playwright-based tests or manual testing instead.
"""
pass
@pytest.mark.integration
@pytest.mark.skip(
reason="Requires manual authentication - test with Playwright instead"
)
async def test_disable_indexing_button_functionality():
"""Test that clicking Disable Indexing button revokes background access.
NOTE: This test is skipped because programmatic login to Nextcloud is complex.
Use Playwright-based tests or manual testing instead.
"""
pass
@@ -1,47 +0,0 @@
"""Integration tests for multi-user BasicAuth pass-through mode.
Tests that BasicAuth credentials are extracted from request headers
and passed through to Nextcloud APIs without storage (stateless).
"""
import pytest
@pytest.mark.integration
async def test_basic_auth_pass_through_notes_list(nc_mcp_basic_auth_client):
"""Test BasicAuth pass-through with notes list tool."""
# Call tool - BasicAuth header is set at connection level by fixture
response = await nc_mcp_basic_auth_client.call_tool("nc_notes_list", {})
# Verify tool executed successfully with pass-through auth
assert response is not None
assert "results" in response or "content" in response
@pytest.mark.integration
async def test_basic_auth_pass_through_notes_create(nc_mcp_basic_auth_client):
"""Test BasicAuth pass-through with notes create tool."""
# Create a note using BasicAuth
response = await nc_mcp_basic_auth_client.call_tool(
"nc_notes_create",
{
"title": "BasicAuth Test Note",
"content": "This note was created via BasicAuth pass-through",
"category": "Test",
},
)
assert response is not None
assert response.get("success") is True or "note_id" in response
@pytest.mark.integration
async def test_basic_auth_pass_through_search(nc_mcp_basic_auth_client):
"""Test BasicAuth pass-through with search tool."""
# Search notes using BasicAuth
response = await nc_mcp_basic_auth_client.call_tool(
"nc_notes_search", {"query": "BasicAuth"}
)
assert response is not None
assert "results" in response or "content" in response
@@ -1,104 +0,0 @@
"""Test Astrolabe integration with multiple MCP server deployments.
This test suite verifies that the Astrolabe app can be dynamically configured
to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.).
The configuration is managed dynamically during tests using the
configure_astrolabe_for_mcp_server fixture, which allows testing multiple
deployment scenarios without requiring static post-installation configuration.
"""
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
class TestAstrolabeMultiServerIntegration:
"""Test suite for Astrolabe integration with multiple MCP servers."""
@pytest.mark.parametrize(
"mcp_server_config",
[
{
"name": "mcp-oauth",
"internal_url": "http://mcp-oauth:8001",
"public_url": "http://localhost:8001",
},
{
"name": "mcp-keycloak",
"internal_url": "http://mcp-keycloak:8002",
"public_url": "http://localhost:8002",
},
# Add more MCP server configurations as needed:
# {
# "name": "mcp-multi-user-basic",
# "internal_url": "http://mcp-multi-user-basic:8000",
# "public_url": "http://localhost:8003",
# },
],
)
async def test_astrolabe_configuration_for_different_servers(
self, configure_astrolabe_for_mcp_server, mcp_server_config
):
"""Test that Astrolabe can be configured for different MCP servers.
This test verifies that:
1. The configure_astrolabe_for_mcp_server fixture successfully configures
the Astrolabe app for different MCP server endpoints
2. OAuth client credentials are properly generated and stored
3. The configuration can be dynamically changed between tests
"""
logger.info(f"Configuring Astrolabe for {mcp_server_config['name']}...")
# Configure Astrolabe for the specific MCP server
credentials = await configure_astrolabe_for_mcp_server(
mcp_server_internal_url=mcp_server_config["internal_url"],
mcp_server_public_url=mcp_server_config["public_url"],
)
# Verify credentials were returned
assert "client_id" in credentials
assert "client_secret" in credentials
assert credentials["client_id"] == "nextcloudMcpServerUIPublicClient"
assert len(credentials["client_secret"]) > 0
logger.info(
f"✓ Astrolabe successfully configured for {mcp_server_config['name']}"
)
logger.info(f" Internal URL: {mcp_server_config['internal_url']}")
logger.info(f" Public URL: {mcp_server_config['public_url']}")
logger.info(f" Client ID: {credentials['client_id']}")
logger.info(f" Client Secret: {credentials['client_secret'][:8]}...")
async def test_astrolabe_reconfiguration(self, configure_astrolabe_for_mcp_server):
"""Test that Astrolabe can be reconfigured multiple times in the same session.
This verifies that the OAuth client can be recreated with different
settings without conflicts.
"""
# First configuration: mcp-oauth
logger.info("First configuration: mcp-oauth")
credentials1 = await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-oauth:8001",
mcp_server_public_url="http://localhost:8001",
)
assert credentials1["client_id"] == "nextcloudMcpServerUIPublicClient"
# Second configuration: mcp-keycloak (reconfiguration)
logger.info("Second configuration: mcp-keycloak (reconfiguration)")
credentials2 = await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-keycloak:8002",
mcp_server_public_url="http://localhost:8002",
)
assert credentials2["client_id"] == "nextcloudMcpServerUIPublicClient"
# Client secrets should be different (new client created)
assert credentials1["client_secret"] != credentials2["client_secret"]
logger.info("✓ Astrolabe successfully reconfigured without conflicts")
+1 -7
View File
@@ -10,14 +10,8 @@ logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server):
async def test_capture_settings_page(browser):
"""Capture what's actually rendered on the personal settings page."""
# Configure Astrolabe for mcp-oauth server
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-oauth:8001",
mcp_server_public_url="http://localhost:8001",
)
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
+5 -23
View File
@@ -44,32 +44,14 @@ async def nc_admin_http_client(nextcloud_credentials):
@pytest.fixture(scope="module")
async def configure_astrolabe_for_tests(configure_astrolabe_for_mcp_server):
"""Configure Astrolabe to connect to mcp-oauth server before running tests.
This module-scoped fixture ensures Astrolabe is properly configured
for the mcp-oauth server (http://localhost:8001) before any tests run.
"""
logger.info("Configuring Astrolabe for mcp-oauth server...")
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-oauth:8001",
mcp_server_public_url="http://localhost:8001",
)
logger.info("✓ Astrolabe configured for mcp-oauth server")
@pytest.fixture(scope="module")
async def authorized_nc_session(
browser, nextcloud_credentials, configure_astrolabe_for_tests
):
async def authorized_nc_session(browser, nextcloud_credentials):
"""Module-scoped fixture that logs in and authorizes the NC PHP app once.
This fixture:
1. Configures Astrolabe for mcp-oauth server (via configure_astrolabe_for_tests)
2. Creates a browser context
3. Logs in to Nextcloud
4. Authorizes the MCP Server UI app (if not already authorized)
5. Returns the page for use in all tests
1. Creates a browser context
2. Logs in to Nextcloud
3. Authorizes the MCP Server UI app (if not already authorized)
4. Returns the page for use in all tests
The authorization is done once and reused for all tests in this module.
"""
-241
View File
@@ -1,241 +0,0 @@
"""Unit tests for BasicAuthMiddleware."""
import base64
import pytest
from nextcloud_mcp_server.app import BasicAuthMiddleware
class MockApp:
"""Mock ASGI app for testing middleware."""
def __init__(self):
self.called = False
self.received_scope = None
async def __call__(self, scope, receive, send):
self.called = True
self.received_scope = scope
@pytest.mark.unit
async def test_basic_auth_middleware_valid_credentials():
"""Test that middleware correctly extracts valid BasicAuth credentials."""
# Arrange
mock_app = MockApp()
middleware = BasicAuthMiddleware(mock_app)
credentials = base64.b64encode(b"admin:password123").decode("utf-8")
scope = {
"type": "http",
"headers": [(b"authorization", f"Basic {credentials}".encode())],
}
# Act
await middleware(scope, None, None) # type: ignore[arg-type]
# Assert
assert mock_app.called
assert "state" in scope
assert "basic_auth" in scope["state"]
assert scope["state"]["basic_auth"]["username"] == "admin"
assert scope["state"]["basic_auth"]["password"] == "password123"
@pytest.mark.unit
async def test_basic_auth_middleware_password_with_colon():
"""Test that middleware handles passwords containing colons."""
# Arrange
mock_app = MockApp()
middleware = BasicAuthMiddleware(mock_app)
# Password contains colon - should split on first colon only
credentials = base64.b64encode(b"user:pass:word:123").decode("utf-8")
scope = {
"type": "http",
"headers": [(b"authorization", f"Basic {credentials}".encode())],
}
# Act
await middleware(scope, None, None) # type: ignore[arg-type]
# Assert
assert scope["state"]["basic_auth"]["username"] == "user"
assert scope["state"]["basic_auth"]["password"] == "pass:word:123"
@pytest.mark.unit
async def test_basic_auth_middleware_invalid_base64():
"""Test that middleware handles invalid base64 encoding gracefully."""
# Arrange
mock_app = MockApp()
middleware = BasicAuthMiddleware(mock_app)
scope = {
"type": "http",
"headers": [(b"authorization", b"Basic INVALID_BASE64!!!")],
}
# Act
await middleware(scope, None, None) # type: ignore[arg-type]
# Assert
assert mock_app.called
# Should not have basic_auth in state due to error
assert "basic_auth" not in scope.get("state", {})
@pytest.mark.unit
async def test_basic_auth_middleware_missing_authorization_header():
"""Test that middleware handles missing Authorization header."""
# Arrange
mock_app = MockApp()
middleware = BasicAuthMiddleware(mock_app)
scope = {
"type": "http",
"headers": [],
}
# Act
await middleware(scope, None, None) # type: ignore[arg-type]
# Assert
assert mock_app.called
# Should not have basic_auth in state
assert "basic_auth" not in scope.get("state", {})
@pytest.mark.unit
async def test_basic_auth_middleware_wrong_auth_scheme():
"""Test that middleware ignores non-Basic auth schemes."""
# Arrange
mock_app = MockApp()
middleware = BasicAuthMiddleware(mock_app)
scope = {
"type": "http",
"headers": [(b"authorization", b"Bearer some_token")],
}
# Act
await middleware(scope, None, None) # type: ignore[arg-type]
# Assert
assert mock_app.called
# Should not have basic_auth in state
assert "basic_auth" not in scope.get("state", {})
@pytest.mark.unit
async def test_basic_auth_middleware_malformed_credentials():
"""Test that middleware handles credentials without colon separator."""
# Arrange
mock_app = MockApp()
middleware = BasicAuthMiddleware(mock_app)
# Credentials without colon separator
credentials = base64.b64encode(b"username_no_password").decode("utf-8")
scope = {
"type": "http",
"headers": [(b"authorization", f"Basic {credentials}".encode())],
}
# Act
await middleware(scope, None, None) # type: ignore[arg-type]
# Assert
assert mock_app.called
# Should not have basic_auth in state due to error
assert "basic_auth" not in scope.get("state", {})
@pytest.mark.unit
async def test_basic_auth_middleware_non_http_scope():
"""Test that middleware passes through non-HTTP scopes unchanged."""
# Arrange
mock_app = MockApp()
middleware = BasicAuthMiddleware(mock_app)
scope = {
"type": "websocket",
"headers": [(b"authorization", b"Basic dXNlcjpwYXNz")],
}
# Act
await middleware(scope, None, None) # type: ignore[arg-type]
# Assert
assert mock_app.called
# Should not process websocket scopes
assert "state" not in scope
@pytest.mark.unit
async def test_basic_auth_middleware_preserves_existing_state():
"""Test that middleware preserves existing state data."""
# Arrange
mock_app = MockApp()
middleware = BasicAuthMiddleware(mock_app)
credentials = base64.b64encode(b"user:pass").decode("utf-8")
scope = {
"type": "http",
"headers": [(b"authorization", f"Basic {credentials}".encode())],
"state": {"existing_key": "existing_value"},
}
# Act
await middleware(scope, None, None) # type: ignore[arg-type]
# Assert
assert mock_app.called
assert scope["state"]["existing_key"] == "existing_value"
assert scope["state"]["basic_auth"]["username"] == "user"
assert scope["state"]["basic_auth"]["password"] == "pass"
@pytest.mark.unit
async def test_basic_auth_middleware_empty_password():
"""Test that middleware handles empty passwords."""
# Arrange
mock_app = MockApp()
middleware = BasicAuthMiddleware(mock_app)
credentials = base64.b64encode(b"user:").decode("utf-8")
scope = {
"type": "http",
"headers": [(b"authorization", f"Basic {credentials}".encode())],
}
# Act
await middleware(scope, None, None) # type: ignore[arg-type]
# Assert
assert mock_app.called
assert scope["state"]["basic_auth"]["username"] == "user"
assert scope["state"]["basic_auth"]["password"] == ""
@pytest.mark.unit
async def test_basic_auth_middleware_unicode_credentials():
"""Test that middleware handles Unicode characters in credentials."""
# Arrange
mock_app = MockApp()
middleware = BasicAuthMiddleware(mock_app)
# Username and password with Unicode characters
credentials = base64.b64encode("üser:pässwörd".encode("utf-8")).decode("utf-8")
scope = {
"type": "http",
"headers": [(b"authorization", f"Basic {credentials}".encode())],
}
# Act
await middleware(scope, None, None) # type: ignore[arg-type]
# Assert
assert mock_app.called
assert scope["state"]["basic_auth"]["username"] == "üser"
assert scope["state"]["basic_auth"]["password"] == "pässwörd"
-992
View File
@@ -1,992 +0,0 @@
"""Unit tests for configuration validation and mode detection.
Tests cover:
- Mode detection logic
- Configuration validation for each mode
- Error message generation
- Edge cases and boundary conditions
"""
import os
from unittest.mock import patch
from nextcloud_mcp_server.config import Settings
from nextcloud_mcp_server.config_validators import (
AuthMode,
detect_auth_mode,
get_mode_summary,
validate_configuration,
)
class TestModeDetection:
"""Test auth mode detection from configuration."""
def test_smithery_mode_detection(self):
"""Test Smithery mode is detected from environment variable."""
settings = Settings()
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
mode = detect_auth_mode(settings)
assert mode == AuthMode.SMITHERY_STATELESS
def test_token_exchange_mode_detection(self):
"""Test token exchange mode is detected."""
settings = Settings(
nextcloud_host="http://localhost",
enable_token_exchange=True,
)
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
def test_multi_user_basic_mode_detection(self):
"""Test multi-user BasicAuth mode is detected."""
settings = Settings(
nextcloud_host="http://localhost",
enable_multi_user_basic_auth=True,
)
mode = detect_auth_mode(settings)
assert mode == AuthMode.MULTI_USER_BASIC
def test_single_user_basic_mode_detection(self):
"""Test single-user BasicAuth mode is detected."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
)
mode = detect_auth_mode(settings)
assert mode == AuthMode.SINGLE_USER_BASIC
def test_oauth_single_audience_default(self):
"""Test OAuth single-audience is default mode."""
settings = Settings(
nextcloud_host="http://localhost",
)
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
def test_mode_priority_smithery_over_all(self):
"""Test Smithery mode has highest priority."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
enable_token_exchange=True,
enable_multi_user_basic_auth=True,
)
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
mode = detect_auth_mode(settings)
assert mode == AuthMode.SMITHERY_STATELESS
def test_mode_priority_token_exchange_over_basic(self):
"""Test token exchange has priority over BasicAuth."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
enable_token_exchange=True,
)
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
class TestSingleUserBasicValidation:
"""Test validation for single-user BasicAuth mode."""
def test_valid_minimal_config(self):
"""Test valid minimal single-user BasicAuth config."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.SINGLE_USER_BASIC
assert len(errors) == 0
def test_valid_with_vector_sync(self):
"""Test valid config with vector sync enabled."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
vector_sync_enabled=True,
qdrant_location=":memory:",
ollama_base_url="http://ollama:11434",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.SINGLE_USER_BASIC
assert len(errors) == 0
def test_missing_required_host(self):
"""Test error when NEXTCLOUD_HOST is missing."""
settings = Settings(
nextcloud_username="admin",
nextcloud_password="password",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.SINGLE_USER_BASIC
assert any("nextcloud_host" in err.lower() for err in errors)
def test_missing_required_username(self):
"""Test that partial credentials fall back to OAuth mode."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_password="password", # Password without username
)
mode, errors = validate_configuration(settings)
# Mode detection requires BOTH username AND password for single-user BasicAuth
# If only one is present, it defaults to OAuth single-audience
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
# In OAuth mode, having a password set is forbidden
assert any("nextcloud_password" in err.lower() for err in errors)
def test_missing_required_password(self):
"""Test that partial credentials fall back to OAuth mode."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin", # Username without password
)
mode, errors = validate_configuration(settings)
# Mode detection requires BOTH username AND password for single-user BasicAuth
# If only one is present, it defaults to OAuth single-audience
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
# In OAuth mode, having a username set is forbidden
assert any("nextcloud_username" in err.lower() for err in errors)
def test_forbidden_multi_user_basic_auth(self):
"""Test error when ENABLE_MULTI_USER_BASIC_AUTH is set."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
enable_multi_user_basic_auth=True,
)
# Note: This will detect as MULTI_USER_BASIC due to priority
mode, errors = validate_configuration(settings)
assert mode == AuthMode.MULTI_USER_BASIC
# It will fail multi-user validation because username/password are forbidden
assert len(errors) > 0
def test_forbidden_token_exchange(self):
"""Test error when ENABLE_TOKEN_EXCHANGE is set."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
enable_token_exchange=True,
)
# Note: This will detect as OAUTH_TOKEN_EXCHANGE due to priority
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
# It will fail OAuth validation
def test_vector_sync_without_embedding_provider_uses_fallback(self):
"""Test that vector sync works with Simple provider fallback (no config needed)."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
vector_sync_enabled=True,
qdrant_location=":memory:",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.SINGLE_USER_BASIC
# Should pass - Simple provider is always available as fallback
assert len(errors) == 0
class TestMultiUserBasicValidation:
"""Test validation for multi-user BasicAuth mode."""
def test_valid_minimal_config(self):
"""Test valid minimal multi-user BasicAuth config."""
settings = Settings(
nextcloud_host="http://localhost",
enable_multi_user_basic_auth=True,
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.MULTI_USER_BASIC
assert len(errors) == 0
def test_valid_with_offline_access(self):
"""Test valid config with offline access enabled."""
settings = Settings(
nextcloud_host="http://localhost",
enable_multi_user_basic_auth=True,
enable_offline_access=True,
oidc_client_id="test-client",
oidc_client_secret="test-secret",
token_encryption_key="test-key-" + "a" * 32,
token_storage_db="/tmp/tokens.db",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.MULTI_USER_BASIC
assert len(errors) == 0
def test_missing_required_host(self):
"""Test error when NEXTCLOUD_HOST is missing."""
settings = Settings(
enable_multi_user_basic_auth=True,
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.MULTI_USER_BASIC
assert any("nextcloud_host" in err.lower() for err in errors)
def test_forbidden_username_password(self):
"""Test error when NEXTCLOUD_USERNAME/PASSWORD are set."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
enable_multi_user_basic_auth=True,
)
mode, errors = validate_configuration(settings)
# Multi-user BasicAuth has higher priority than single-user in detection
# (explicit flags come before credentials)
assert mode == AuthMode.MULTI_USER_BASIC
# Should report errors for forbidden username/password
assert any("nextcloud_username" in err.lower() for err in errors)
assert any("nextcloud_password" in err.lower() for err in errors)
def test_offline_access_missing_oauth_credentials(self):
"""Test that offline access works without OAuth credentials (will use DCR)."""
settings = Settings(
nextcloud_host="http://localhost",
enable_multi_user_basic_auth=True,
enable_offline_access=True,
token_encryption_key="test-key-" + "a" * 32,
token_storage_db="/tmp/tokens.db",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.MULTI_USER_BASIC
# No errors - DCR will be used as fallback (consistent with OAuth modes)
assert len(errors) == 0
def test_offline_access_missing_encryption_key(self):
"""Test error when offline access enabled but encryption key missing."""
settings = Settings(
nextcloud_host="http://localhost",
enable_multi_user_basic_auth=True,
enable_offline_access=True,
oidc_client_id="test-client",
oidc_client_secret="test-secret",
token_storage_db="/tmp/tokens.db",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.MULTI_USER_BASIC
assert any("token_encryption_key" in err.lower() for err in errors)
def test_vector_sync_auto_enables_background_ops_in_multi_user_mode(self):
"""Test vector sync automatically enables background operations in multi-user mode (ADR-021)."""
# Before ADR-021: This would have failed validation (required explicit ENABLE_OFFLINE_ACCESS)
# After ADR-021: vector_sync_enabled auto-enables background operations
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"ENABLE_MULTI_USER_BASIC_AUTH": "true",
"VECTOR_SYNC_ENABLED": "true", # Using old name for backward compat test
"QDRANT_LOCATION": ":memory:",
"OLLAMA_BASE_URL": "http://ollama:11434",
"TOKEN_ENCRYPTION_KEY": "test-key",
"TOKEN_STORAGE_DB": "/tmp/test.db",
"NEXTCLOUD_OIDC_CLIENT_ID": "test-client-id",
"NEXTCLOUD_OIDC_CLIENT_SECRET": "test-client-secret",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode, errors = validate_configuration(settings)
assert mode == AuthMode.MULTI_USER_BASIC
# Should have no errors - background operations auto-enabled
assert len(errors) == 0
# Verify background operations were auto-enabled
assert settings.enable_offline_access is True
class TestOAuthSingleAudienceValidation:
"""Test validation for OAuth single-audience mode."""
def test_valid_minimal_config(self):
"""Test valid minimal OAuth single-audience config."""
settings = Settings(
nextcloud_host="http://localhost",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert len(errors) == 0
def test_valid_with_static_credentials(self):
"""Test valid config with static OAuth credentials."""
settings = Settings(
nextcloud_host="http://localhost",
oidc_client_id="test-client",
oidc_client_secret="test-secret",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert len(errors) == 0
def test_valid_with_offline_access(self):
"""Test valid config with offline access."""
settings = Settings(
nextcloud_host="http://localhost",
oidc_client_id="test-client",
oidc_client_secret="test-secret",
enable_offline_access=True,
token_encryption_key="test-key-" + "a" * 32,
token_storage_db="/tmp/tokens.db",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert len(errors) == 0
def test_forbidden_username_password(self):
"""Test that username/password trigger single-user mode instead."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
)
mode, errors = validate_configuration(settings)
# This should detect as SINGLE_USER_BASIC
assert mode == AuthMode.SINGLE_USER_BASIC
def test_offline_access_missing_encryption_key(self):
"""Test error when offline access enabled but encryption key missing."""
settings = Settings(
nextcloud_host="http://localhost",
enable_offline_access=True,
token_storage_db="/tmp/tokens.db",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert any("token_encryption_key" in err.lower() for err in errors)
def test_vector_sync_auto_enables_background_ops_in_oauth_mode(self):
"""Test vector sync automatically enables background operations in OAuth mode (ADR-021)."""
# Before ADR-021: This would have failed validation (required explicit ENABLE_OFFLINE_ACCESS)
# After ADR-021: vector_sync_enabled auto-enables background operations in multi-user modes
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"VECTOR_SYNC_ENABLED": "true",
"QDRANT_LOCATION": ":memory:",
"OLLAMA_BASE_URL": "http://ollama:11434",
"TOKEN_ENCRYPTION_KEY": "test-key",
"TOKEN_STORAGE_DB": "/tmp/test.db",
# Note: No username/password = OAuth mode
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
# Should have no errors - background operations auto-enabled
assert len(errors) == 0
# Verify background operations were auto-enabled
assert settings.enable_offline_access is True
class TestOAuthTokenExchangeValidation:
"""Test validation for OAuth token exchange mode."""
def test_valid_minimal_config(self):
"""Test valid minimal OAuth token exchange config."""
settings = Settings(
nextcloud_host="http://localhost",
enable_token_exchange=True,
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
assert len(errors) == 0
def test_valid_with_credentials(self):
"""Test valid config with OAuth credentials."""
settings = Settings(
nextcloud_host="http://localhost",
enable_token_exchange=True,
oidc_client_id="test-client",
oidc_client_secret="test-secret",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
assert len(errors) == 0
def test_forbidden_username_password(self):
"""Test error when username/password are set."""
settings = Settings(
nextcloud_host="http://localhost",
enable_token_exchange=True,
nextcloud_username="admin",
nextcloud_password="password",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
assert any("nextcloud_username" in err.lower() for err in errors)
assert any("nextcloud_password" in err.lower() for err in errors)
class TestSmitheryValidation:
"""Test validation for Smithery stateless mode."""
def test_valid_empty_config(self):
"""Test valid empty config for Smithery mode."""
settings = Settings()
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
mode, errors = validate_configuration(settings)
assert mode == AuthMode.SMITHERY_STATELESS
assert len(errors) == 0
def test_forbidden_nextcloud_host(self):
"""Test error when NEXTCLOUD_HOST is set."""
settings = Settings(
nextcloud_host="http://localhost",
)
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
mode, errors = validate_configuration(settings)
assert mode == AuthMode.SMITHERY_STATELESS
assert any("nextcloud_host" in err.lower() for err in errors)
def test_forbidden_credentials(self):
"""Test error when credentials are set."""
settings = Settings(
nextcloud_username="admin",
nextcloud_password="password",
)
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
mode, errors = validate_configuration(settings)
assert mode == AuthMode.SMITHERY_STATELESS
assert any("nextcloud_username" in err.lower() for err in errors)
def test_forbidden_vector_sync(self):
"""Test error when vector sync is enabled."""
settings = Settings(
vector_sync_enabled=True,
)
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
mode, errors = validate_configuration(settings)
assert mode == AuthMode.SMITHERY_STATELESS
assert any("vector_sync_enabled" in err.lower() for err in errors)
class TestModeSummary:
"""Test mode summary generation."""
def test_single_user_basic_summary(self):
"""Test summary for single-user BasicAuth mode."""
summary = get_mode_summary(AuthMode.SINGLE_USER_BASIC)
assert "single_user_basic" in summary
assert "NEXTCLOUD_HOST" in summary
assert "NEXTCLOUD_USERNAME" in summary
assert "NEXTCLOUD_PASSWORD" in summary
assert "VECTOR_SYNC_ENABLED" in summary
def test_smithery_summary(self):
"""Test summary for Smithery mode."""
summary = get_mode_summary(AuthMode.SMITHERY_STATELESS)
assert "smithery" in summary
assert "session" in summary.lower()
assert "(none" in summary # No required config
def test_oauth_token_exchange_summary(self):
"""Test summary for OAuth token exchange mode."""
summary = get_mode_summary(AuthMode.OAUTH_TOKEN_EXCHANGE)
assert "oauth_exchange" in summary
assert "ENABLE_TOKEN_EXCHANGE" in summary
assert "RFC 8693" in summary
class TestEdgeCases:
"""Test edge cases and boundary conditions."""
def test_empty_string_treated_as_missing(self):
"""Test that empty strings are treated as missing values."""
settings = Settings(
nextcloud_host="", # Empty string
nextcloud_username="admin",
nextcloud_password="password",
)
mode, errors = validate_configuration(settings)
# Should fail because nextcloud_host is effectively missing
assert any("nextcloud_host" in err.lower() for err in errors)
def test_whitespace_treated_as_missing(self):
"""Test that whitespace-only strings are treated as missing."""
settings = Settings(
nextcloud_host=" ", # Whitespace only
nextcloud_username="admin",
nextcloud_password="password",
)
mode, errors = validate_configuration(settings)
# Should fail because nextcloud_host is effectively missing
assert any("nextcloud_host" in err.lower() for err in errors)
def test_multiple_errors_reported(self):
"""Test that multiple errors are all reported."""
settings = Settings(
# Missing all required fields for single-user BasicAuth
)
mode, errors = validate_configuration(settings)
# Should have errors for missing host (OAuth mode is default)
assert len(errors) > 0
class TestConfigurationConsolidation:
"""Test ADR-021 configuration consolidation and backward compatibility.
Tests verify:
- New variable names work (ENABLE_SEMANTIC_SEARCH, ENABLE_BACKGROUND_OPERATIONS)
- Old variable names still work (VECTOR_SYNC_ENABLED, ENABLE_OFFLINE_ACCESS)
- Deprecation warnings are logged
- Auto-enablement of background operations in multi-user modes
"""
def test_new_semantic_search_variable_name(self):
"""Test ENABLE_SEMANTIC_SEARCH (new name) works correctly."""
with patch.dict(
os.environ,
{
"ENABLE_SEMANTIC_SEARCH": "true",
"QDRANT_LOCATION": ":memory:",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
assert settings.vector_sync_enabled is True
def test_old_vector_sync_variable_name_backward_compat(self):
"""Test VECTOR_SYNC_ENABLED (old name) still works for backward compatibility."""
with patch.dict(
os.environ,
{
"VECTOR_SYNC_ENABLED": "true",
"QDRANT_LOCATION": ":memory:",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
assert settings.vector_sync_enabled is True
def test_new_background_operations_variable_name(self):
"""Test ENABLE_BACKGROUND_OPERATIONS (new name) works correctly."""
with patch.dict(
os.environ,
{
"ENABLE_BACKGROUND_OPERATIONS": "true",
"TOKEN_ENCRYPTION_KEY": "test-key",
"TOKEN_STORAGE_DB": "/tmp/test.db",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
assert settings.enable_offline_access is True
def test_old_offline_access_variable_name_backward_compat(self):
"""Test ENABLE_OFFLINE_ACCESS (old name) still works for backward compatibility."""
with patch.dict(
os.environ,
{
"ENABLE_OFFLINE_ACCESS": "true",
"TOKEN_ENCRYPTION_KEY": "test-key",
"TOKEN_STORAGE_DB": "/tmp/test.db",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
assert settings.enable_offline_access is True
def test_semantic_search_auto_enables_background_ops_in_oauth_mode(self):
"""Test ENABLE_SEMANTIC_SEARCH automatically enables background operations in OAuth mode."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"ENABLE_SEMANTIC_SEARCH": "true",
"QDRANT_LOCATION": ":memory:",
"TOKEN_ENCRYPTION_KEY": "test-key",
"TOKEN_STORAGE_DB": "/tmp/test.db",
# Note: No NEXTCLOUD_USERNAME/PASSWORD = OAuth mode
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
# Semantic search enabled
assert settings.vector_sync_enabled is True
# Background operations auto-enabled (even though not explicitly set)
assert settings.enable_offline_access is True
def test_semantic_search_does_not_auto_enable_in_single_user_mode(self):
"""Test ENABLE_SEMANTIC_SEARCH does NOT auto-enable background ops in single-user mode."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"NEXTCLOUD_USERNAME": "admin",
"NEXTCLOUD_PASSWORD": "password",
"ENABLE_SEMANTIC_SEARCH": "true",
"QDRANT_LOCATION": ":memory:",
# Note: Username/password set = single-user BasicAuth mode
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
# Semantic search enabled
assert settings.vector_sync_enabled is True
# Background operations NOT auto-enabled (not needed in single-user mode)
assert settings.enable_offline_access is False
def test_explicit_background_ops_still_works(self):
"""Test explicitly setting ENABLE_BACKGROUND_OPERATIONS works even without semantic search."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"ENABLE_BACKGROUND_OPERATIONS": "true",
"TOKEN_ENCRYPTION_KEY": "test-key",
"TOKEN_STORAGE_DB": "/tmp/test.db",
# Note: No semantic search enabled
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
# Semantic search NOT enabled
assert settings.vector_sync_enabled is False
# Background operations explicitly enabled
assert settings.enable_offline_access is True
def test_both_old_and_new_semantic_search_names_prefers_new(self):
"""Test setting both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED uses new name."""
with patch.dict(
os.environ,
{
"ENABLE_SEMANTIC_SEARCH": "true",
"VECTOR_SYNC_ENABLED": "false", # Old name says false
"QDRANT_LOCATION": ":memory:",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
# Should use new name value (true)
assert settings.vector_sync_enabled is True
def test_both_old_and_new_background_ops_names_prefers_new(self):
"""Test setting both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS uses new name."""
with patch.dict(
os.environ,
{
"ENABLE_BACKGROUND_OPERATIONS": "true",
"ENABLE_OFFLINE_ACCESS": "false", # Old name says false
"TOKEN_ENCRYPTION_KEY": "test-key",
"TOKEN_STORAGE_DB": "/tmp/test.db",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
# Should use new name value (true)
assert settings.enable_offline_access is True
def test_validation_no_longer_requires_both_variables(self):
"""Test validation no longer requires explicit ENABLE_OFFLINE_ACCESS when semantic search enabled."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"ENABLE_MULTI_USER_BASIC_AUTH": "true",
"ENABLE_SEMANTIC_SEARCH": "true",
"QDRANT_LOCATION": ":memory:",
"TOKEN_ENCRYPTION_KEY": "test-key",
"TOKEN_STORAGE_DB": "/tmp/test.db",
# OAuth credentials required for app password retrieval (when background ops enabled)
"NEXTCLOUD_OIDC_CLIENT_ID": "test-client-id",
"NEXTCLOUD_OIDC_CLIENT_SECRET": "test-client-secret",
# Note: ENABLE_OFFLINE_ACCESS not set - should auto-enable
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode, errors = validate_configuration(settings)
# Should have no validation errors
# (Previously would have required explicit ENABLE_OFFLINE_ACCESS)
assert len(errors) == 0
assert mode == AuthMode.MULTI_USER_BASIC
# Verify background operations were auto-enabled
assert settings.enable_offline_access is True
class TestExplicitModeSelection:
"""Test ADR-021 explicit mode selection via MCP_DEPLOYMENT_MODE.
Tests verify:
- Explicit mode selection works for all modes
- Invalid mode names raise ValueError
- Explicit mode takes precedence over auto-detection
"""
def test_explicit_single_user_basic_mode(self):
"""Test explicit single_user_basic mode selection."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": "single_user_basic",
"NEXTCLOUD_USERNAME": "admin",
"NEXTCLOUD_PASSWORD": "password",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.SINGLE_USER_BASIC
def test_explicit_multi_user_basic_mode(self):
"""Test explicit multi_user_basic mode selection."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": "multi_user_basic",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.MULTI_USER_BASIC
def test_explicit_oauth_single_audience_mode(self):
"""Test explicit oauth_single_audience mode selection."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": "oauth_single_audience",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
def test_explicit_oauth_token_exchange_mode(self):
"""Test explicit oauth_token_exchange mode selection."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": "oauth_token_exchange",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
def test_explicit_smithery_mode(self):
"""Test explicit smithery mode selection."""
with patch.dict(
os.environ,
{
"MCP_DEPLOYMENT_MODE": "smithery",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.SMITHERY_STATELESS
def test_invalid_deployment_mode_raises_error(self):
"""Test invalid MCP_DEPLOYMENT_MODE raises ValueError."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": "invalid_mode",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
# Should raise ValueError with clear message
try:
detect_auth_mode(settings)
assert False, "Should have raised ValueError"
except ValueError as e:
assert "Invalid MCP_DEPLOYMENT_MODE" in str(e)
assert "invalid_mode" in str(e)
assert "Valid values:" in str(e)
def test_explicit_mode_overrides_auto_detection(self):
"""Test explicit mode takes precedence over auto-detection."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"NEXTCLOUD_USERNAME": "admin", # Would auto-detect as single_user_basic
"NEXTCLOUD_PASSWORD": "password",
"MCP_DEPLOYMENT_MODE": "oauth_single_audience", # Explicit override
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode = detect_auth_mode(settings)
# Should use explicit mode, not auto-detected mode
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
def test_case_insensitive_mode_names(self):
"""Test MCP_DEPLOYMENT_MODE is case-insensitive."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": "OAUTH_SINGLE_AUDIENCE", # Uppercase
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
def test_whitespace_in_mode_name_stripped(self):
"""Test whitespace in MCP_DEPLOYMENT_MODE is stripped."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": " oauth_single_audience ", # Whitespace
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.6.0"
version = "0.4.4"
tag_format = "astrolabe-v$version"
version_scheme = "semver"
update_changelog_on_bump = true
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- name: Get version matrix
id: versions
uses: icewind1991/nextcloud-version-matrix@c2bf575a3516752db5ce2915499d3f694885e2c7 # v1.0.0
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.0.0
php-lint:
runs-on: ubuntu-latest
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
- name: Create Pull Request
if: steps.checkout.outcome == 'success'
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ secrets.COMMAND_BOT_PAT }}
commit-message: 'fix(deps): Fix npm audit'
@@ -85,7 +85,7 @@ jobs:
continue-on-error: true
- name: Create Pull Request
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ secrets.COMMAND_BOT_PAT }}
commit-message: 'chore(dev-deps): Bump nextcloud/ocp package'
-23
View File
@@ -25,29 +25,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Requires external MCP server deployment
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
## astrolabe-v0.6.0 (2025-12-22)
### Feat
- **config**: enable DCR for multi-user BasicAuth with offline access
- **astrolabe**: implement app password provisioning for multi-user background sync
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
## astrolabe-v0.5.0 (2025-12-20)
### Feat
- **auth**: add multi-user BasicAuth pass-through mode
- **astrolabe**: add dynamic MCP server configuration for testing
### Fix
- **config**: address reviewer feedback
### Refactor
- **config**: centralize configuration validation and simplify startup
## astrolabe-v0.4.4 (2025-12-20)
### Fix
+1 -1
View File
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
]]></description>
<version>0.6.0</version>
<version>0.4.4</version>
<licence>agpl</licence>
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
<namespace>Astrolabe</namespace>
-22
View File
@@ -34,28 +34,6 @@ return [
'verb' => 'POST',
],
// Background sync credentials routes
[
'name' => 'credentials#storeAppPassword',
'url' => '/api/v1/background-sync/credentials',
'verb' => 'POST',
],
[
'name' => 'credentials#getCredentials',
'url' => '/api/v1/background-sync/credentials/{userId}',
'verb' => 'GET',
],
[
'name' => 'credentials#deleteCredentials',
'url' => '/api/v1/background-sync/credentials',
'verb' => 'DELETE',
],
[
'name' => 'credentials#getStatus',
'url' => '/api/v1/background-sync/status',
'verb' => 'GET',
],
// Vector search API routes
[
'name' => 'api#search',
-17
View File
@@ -4,15 +4,11 @@ declare(strict_types=1);
namespace OCA\Astrolabe\AppInfo;
use OCA\Astrolabe\Listener\AstrolabeAdminSettingsListener;
use OCA\Astrolabe\Search\SemanticSearchProvider;
use OCA\Astrolabe\Settings\AstrolabeAdminSettings;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
class Application extends App implements IBootstrap {
public const APP_ID = 'astrolabe';
@@ -25,19 +21,6 @@ class Application extends App implements IBootstrap {
public function register(IRegistrationContext $context): void {
// Register unified search provider for semantic search
$context->registerSearchProvider(SemanticSearchProvider::class);
// Register declarative admin settings
$context->registerDeclarativeSettings(AstrolabeAdminSettings::class);
// Register event listeners for declarative settings
$context->registerEventListener(
DeclarativeSettingsGetValueEvent::class,
AstrolabeAdminSettingsListener::class
);
$context->registerEventListener(
DeclarativeSettingsSetValueEvent::class,
AstrolabeAdminSettingsListener::class
);
}
public function boot(IBootContext $context): void {
@@ -97,12 +97,6 @@ class ApiController extends Controller {
// TODO: Add flash message/notification for user feedback
} else {
$this->logger->info("Successfully revoked background access for user $userId");
// Delete local OAuth tokens from Nextcloud config
// This ensures hasBackgroundAccess() returns false on next page load
$this->tokenStorage->deleteUserToken($userId);
$this->logger->debug("Deleted local OAuth tokens for user $userId");
// TODO: Add success flash message/notification
}
@@ -1,258 +0,0 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Controller;
use OCA\Astrolabe\Service\McpServerClient;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* Controller for managing background sync credentials (app passwords).
*
* Handles storing and validating app passwords for multi-user BasicAuth mode.
*/
class CredentialsController extends Controller {
private $tokenStorage;
private $userSession;
private $logger;
private $config;
private $client;
private $httpClientService;
private $urlGenerator;
public function __construct(
string $appName,
IRequest $request,
McpTokenStorage $tokenStorage,
IUserSession $userSession,
LoggerInterface $logger,
IConfig $config,
McpServerClient $client,
IClientService $httpClientService,
IURLGenerator $urlGenerator,
) {
parent::__construct($appName, $request);
$this->tokenStorage = $tokenStorage;
$this->userSession = $userSession;
$this->logger = $logger;
$this->config = $config;
$this->client = $client;
$this->httpClientService = $httpClientService;
$this->urlGenerator = $urlGenerator;
}
/**
* Store app password for background sync.
*
* Validates the app password by making a test request to Nextcloud,
* then stores it encrypted if valid.
*
* @param string $appPassword Nextcloud app password
* @return JSONResponse
*/
#[NoAdminRequired]
public function storeAppPassword(string $appPassword): JSONResponse {
$user = $this->userSession->getUser();
if (!$user) {
$this->logger->error('storeAppPassword called without authenticated user');
return new JSONResponse([
'success' => false,
'error' => 'User not authenticated'
], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
// Validate app password format (xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
if (!preg_match('/^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$/', $appPassword)) {
$this->logger->warning("Invalid app password format for user: $userId");
return new JSONResponse([
'success' => false,
'error' => 'Invalid app password format'
], Http::STATUS_BAD_REQUEST);
}
// Validate app password with Nextcloud
$isValid = $this->validateAppPassword($userId, $appPassword);
if (!$isValid) {
$this->logger->warning("App password validation failed for user: $userId");
return new JSONResponse([
'success' => false,
'error' => 'Invalid app password. Please check the password and try again.'
], Http::STATUS_UNAUTHORIZED);
}
// Store encrypted app password
try {
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
$this->logger->info("Successfully stored app password for user: $userId");
return new JSONResponse([
'success' => true,
'message' => 'App password saved successfully'
], Http::STATUS_OK);
} catch (\Exception $e) {
$this->logger->error("Failed to store app password for user $userId", [
'error' => $e->getMessage()
]);
return new JSONResponse([
'success' => false,
'error' => 'Failed to save app password'
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Validate app password by making a test request to Nextcloud.
*
* @param string $userId User ID
* @param string $appPassword App password to validate
* @return bool True if valid, false otherwise
*/
private function validateAppPassword(string $userId, string $appPassword): bool {
try {
// Use 127.0.0.1 for internal validation (we're running inside Nextcloud container)
// Using IP address instead of 'localhost' to avoid Nextcloud's overwrite.cli.url rewriting
// getAbsoluteURL() returns the external URL which isn't accessible from inside the container
$baseUrl = 'http://127.0.0.1';
// Make a test request to Nextcloud API with BasicAuth
// Using OCS API user endpoint as a lightweight test
$testUrl = $baseUrl . '/ocs/v1.php/cloud/user?format=json';
$this->logger->debug("Validating app password for user: $userId against $testUrl");
// Use Nextcloud's HTTP client
$httpClient = $this->httpClientService->newClient();
$response = $httpClient->get($testUrl, [
'auth' => [$userId, $appPassword],
'headers' => [
'OCS-APIRequest' => 'true',
'Accept' => 'application/json',
],
'timeout' => 10,
]);
$statusCode = $response->getStatusCode();
// Success is 200 OK
if ($statusCode === 200) {
$this->logger->debug("App password validation successful for user: $userId");
return true;
}
$this->logger->warning("App password validation failed for user: $userId (HTTP $statusCode)");
return false;
} catch (\Exception $e) {
$this->logger->error("Exception during app password validation for user $userId", [
'error' => $e->getMessage()
]);
return false;
}
}
/**
* Get background sync credentials status for the current user.
*
* @return JSONResponse
*/
#[NoAdminRequired]
public function getStatus(): JSONResponse {
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse([
'success' => false,
'error' => 'User not authenticated'
], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
return new JSONResponse([
'success' => true,
'has_background_access' => $hasAccess,
'sync_type' => $syncType,
'provisioned_at' => $provisionedAt,
], Http::STATUS_OK);
}
/**
* Get credentials for a specific user (admin only).
*
* Note: This does NOT return the actual password, only metadata.
*
* @param string $userId User ID to check
* @return JSONResponse
*/
public function getCredentials(string $userId): JSONResponse {
// This endpoint should only be accessible by admins
// For now, just return metadata (not actual credentials)
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
return new JSONResponse([
'success' => true,
'user_id' => $userId,
'has_background_access' => $hasAccess,
'sync_type' => $syncType,
'provisioned_at' => $provisionedAt,
], Http::STATUS_OK);
}
/**
* Delete background sync credentials for the current user.
*
* @return JSONResponse
*/
#[NoAdminRequired]
public function deleteCredentials(): JSONResponse {
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse([
'success' => false,
'error' => 'User not authenticated'
], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
try {
// Delete both OAuth tokens and app password (if any exist)
$this->tokenStorage->deleteUserToken($userId);
$this->tokenStorage->deleteBackgroundSyncPassword($userId);
$this->logger->info("Deleted background sync credentials for user: $userId");
return new JSONResponse([
'success' => true,
'message' => 'Credentials deleted successfully'
], Http::STATUS_OK);
} catch (\Exception $e) {
$this->logger->error("Failed to delete credentials for user $userId", [
'error' => $e->getMessage()
]);
return new JSONResponse([
'success' => false,
'error' => 'Failed to delete credentials'
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}
@@ -1,101 +0,0 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Listener;
use OCA\Astrolabe\AppInfo\Application;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IConfig;
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
use Psr\Log\LoggerInterface;
/**
* @template-implements IEventListener<DeclarativeSettingsGetValueEvent|DeclarativeSettingsSetValueEvent>
*/
class AstrolabeAdminSettingsListener implements IEventListener {
public function __construct(
private IConfig $config,
private LoggerInterface $logger,
) {
}
public function handle(Event $event): void {
if (!$event instanceof DeclarativeSettingsGetValueEvent && !$event instanceof DeclarativeSettingsSetValueEvent) {
return;
}
if ($event->getApp() !== Application::APP_ID) {
return;
}
if ($event->getFormId() !== 'astrolabe-admin-settings') {
return;
}
if ($event instanceof DeclarativeSettingsGetValueEvent) {
$this->handleGetValue($event);
} elseif ($event instanceof DeclarativeSettingsSetValueEvent) {
$this->handleSetValue($event);
}
}
private function handleGetValue(DeclarativeSettingsGetValueEvent $event): void {
$fieldId = $event->getFieldId();
// Map field IDs to system config keys
$value = match($fieldId) {
'mcp_server_url' => $this->config->getSystemValue('mcp_server_url', ''),
'mcp_server_api_key' => '****', // Never leak the API key on read
'astrolabe_client_id' => $this->config->getSystemValue('astrolabe_client_id', ''),
'astrolabe_client_secret' => '****', // Never leak the secret on read
default => null,
};
if ($value !== null) {
$event->setValue($value);
}
}
private function handleSetValue(DeclarativeSettingsSetValueEvent $event): void {
$fieldId = $event->getFieldId();
$value = $event->getValue();
// Only save if value is not empty (allow clearing by setting to empty string)
// For password fields, if the value is '****', don't update (user didn't change it)
if ($fieldId === 'mcp_server_api_key' && $value === '****') {
$event->stopPropagation();
return;
}
if ($fieldId === 'astrolabe_client_secret' && $value === '****') {
$event->stopPropagation();
return;
}
try {
match($fieldId) {
'mcp_server_url' => $this->config->setSystemValue('mcp_server_url', (string)$value),
'mcp_server_api_key' => $this->config->setSystemValue('mcp_server_api_key', (string)$value),
'astrolabe_client_id' => $this->config->setSystemValue('astrolabe_client_id', (string)$value),
'astrolabe_client_secret' => $this->config->setSystemValue('astrolabe_client_secret', (string)$value),
default => null,
};
$this->logger->info('Astrolabe admin setting updated', [
'field' => $fieldId,
'app' => Application::APP_ID,
]);
} catch (\Exception $e) {
$this->logger->error('Failed to update Astrolabe admin setting', [
'field' => $fieldId,
'error' => $e->getMessage(),
'app' => Application::APP_ID,
]);
throw $e;
}
$event->stopPropagation();
}
}
-172
View File
@@ -202,176 +202,4 @@ class McpTokenStorage {
return $token['access_token'];
}
/**
* Store app password for background sync.
*
* App passwords are encrypted before storage and used as an alternative
* to OAuth refresh tokens for background sync operations.
*
* @param string $userId User ID
* @param string $appPassword Nextcloud app password
*/
public function storeBackgroundSyncPassword(
string $userId,
string $appPassword,
): void {
try {
// Encrypt app password before storage
$encrypted = $this->crypto->encrypt($appPassword);
// Store in user preferences
$this->config->setUserValue(
$userId,
'astrolabe',
'background_sync_password',
$encrypted
);
// Mark credential type
$this->config->setUserValue(
$userId,
'astrolabe',
'background_sync_type',
'app_password'
);
// Store provisioned timestamp
$this->config->setUserValue(
$userId,
'astrolabe',
'background_sync_provisioned_at',
(string)time()
);
$this->logger->info("Stored background sync app password for user: $userId");
} catch (\Exception $e) {
$this->logger->error("Failed to store app password for user $userId", [
'error' => $e->getMessage()
]);
throw $e;
}
}
/**
* Get app password for background sync.
*
* @param string $userId User ID
* @return string|null Decrypted app password, or null if not set
*/
public function getBackgroundSyncPassword(string $userId): ?string {
try {
$encrypted = $this->config->getUserValue(
$userId,
'astrolabe',
'background_sync_password',
''
);
if (empty($encrypted)) {
return null;
}
// Decrypt app password
return $this->crypto->decrypt($encrypted);
} catch (\Exception $e) {
$this->logger->error("Failed to retrieve app password for user $userId", [
'error' => $e->getMessage()
]);
return null;
}
}
/**
* Delete background sync app password for a user.
*
* @param string $userId User ID
*/
public function deleteBackgroundSyncPassword(string $userId): void {
try {
$this->config->deleteUserValue(
$userId,
'astrolabe',
'background_sync_password'
);
$this->config->deleteUserValue(
$userId,
'astrolabe',
'background_sync_type'
);
$this->config->deleteUserValue(
$userId,
'astrolabe',
'background_sync_provisioned_at'
);
$this->logger->info("Deleted background sync app password for user: $userId");
} catch (\Exception $e) {
$this->logger->error("Failed to delete app password for user $userId", [
'error' => $e->getMessage()
]);
throw $e;
}
}
/**
* Check if user has provisioned background sync access.
*
* Returns true if either OAuth tokens or app password is configured.
*
* @param string $userId User ID
* @return bool True if background sync is provisioned
*/
public function hasBackgroundSyncAccess(string $userId): bool {
// Check for OAuth tokens
$oauthToken = $this->getUserToken($userId);
if ($oauthToken !== null) {
return true;
}
// Check for app password
$appPassword = $this->getBackgroundSyncPassword($userId);
return $appPassword !== null;
}
/**
* Get background sync credential type for a user.
*
* @param string $userId User ID
* @return string|null 'oauth' or 'app_password', or null if not provisioned
*/
public function getBackgroundSyncType(string $userId): ?string {
$type = $this->config->getUserValue(
$userId,
'astrolabe',
'background_sync_type',
''
);
// Fallback to OAuth if tokens exist but type not set
if (empty($type) && $this->getUserToken($userId) !== null) {
return 'oauth';
}
return empty($type) ? null : $type;
}
/**
* Get background sync provisioned timestamp for a user.
*
* @param string $userId User ID
* @return int|null Unix timestamp, or null if not provisioned
*/
public function getBackgroundSyncProvisionedAt(string $userId): ?int {
$timestamp = $this->config->getUserValue(
$userId,
'astrolabe',
'background_sync_provisioned_at',
''
);
return empty($timestamp) ? null : (int)$timestamp;
}
}
@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Settings;
use OCP\IL10N;
use OCP\Settings\DeclarativeSettingsTypes;
use OCP\Settings\IDeclarativeSettingsForm;
class AstrolabeAdminSettings implements IDeclarativeSettingsForm {
public function __construct(
private IL10N $l,
) {
}
public function getSchema(): array {
return [
'id' => 'astrolabe-admin-settings',
'priority' => 10,
'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN,
'section_id' => 'astrolabe',
'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL,
'title' => $this->l->t('MCP Server Configuration'),
'description' => $this->l->t('Configure the connection to your Nextcloud MCP Server'),
'doc_url' => 'https://github.com/cbcoutinho/nextcloud-mcp-server',
'fields' => [
[
'id' => 'mcp_server_url',
'title' => $this->l->t('MCP Server URL'),
'description' => $this->l->t('The base URL of your Nextcloud MCP Server instance (e.g., http://localhost:8000)'),
'type' => DeclarativeSettingsTypes::URL,
'placeholder' => 'http://localhost:8000',
'default' => '',
],
[
'id' => 'mcp_server_api_key',
'title' => $this->l->t('API Key'),
'description' => $this->l->t('Authentication key for the MCP server (leave empty if not required)'),
'type' => DeclarativeSettingsTypes::PASSWORD,
'placeholder' => $this->l->t('Enter API key'),
'default' => '',
],
[
'id' => 'astrolabe_client_id',
'title' => $this->l->t('OAuth Client ID'),
'description' => $this->l->t('The OAuth client ID for Astrolabe (required for multi-user deployments)'),
'type' => DeclarativeSettingsTypes::TEXT,
'placeholder' => $this->l->t('Enter OAuth client ID'),
'default' => '',
],
[
'id' => 'astrolabe_client_secret',
'title' => $this->l->t('OAuth Client Secret'),
'description' => $this->l->t('Optional: Client secret for OAuth. If not set, PKCE will be used as fallback.'),
'type' => DeclarativeSettingsTypes::PASSWORD,
'placeholder' => $this->l->t('Enter client secret (optional)'),
'default' => '',
],
],
];
}
}
+2 -86
View File
@@ -55,87 +55,11 @@ class Personal implements ISettings {
$userId = $user->getUID();
// Fetch server status to determine auth mode
$serverStatus = $this->client->getStatus();
// Check for server connection error
if (isset($serverStatus['error'])) {
return new TemplateResponse(
Application::APP_ID,
'settings/error',
[
'error' => 'Cannot connect to MCP server',
'details' => $serverStatus['error'],
'server_url' => $this->client->getPublicServerUrl(),
],
TemplateResponse::RENDER_AS_BLANK
);
}
// Get auth mode from server (defaults to oauth if not specified)
$authMode = $serverStatus['auth_mode'] ?? 'oauth';
$supportsAppPasswords = $serverStatus['supports_app_passwords'] ?? false;
// Check if user has MCP OAuth token
$token = $this->tokenStorage->getUserToken($userId);
// For multi_user_basic mode with app password support, check if user has app password
if ($authMode === 'multi_user_basic' && $supportsAppPasswords) {
// Check if user has already provided an app password
$hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
if (!$hasBackgroundAccess) {
// No app password yet - show app password entry form
return new TemplateResponse(
Application::APP_ID,
'settings/personal',
[
'serverUrl' => $this->client->getPublicServerUrl(), // Changed from server_url to serverUrl
'serverStatus' => $serverStatus,
'auth_mode' => $authMode,
'authMode' => $authMode, // Add camelCase version for template
'supports_app_passwords' => $supportsAppPasswords,
'supportsAppPasswords' => $supportsAppPasswords, // Add camelCase version
'session' => null, // No session yet
'hasBackgroundAccess' => false, // FIXED: Add missing parameter
'backgroundAccessGranted' => false, // FIXED: Add missing parameter
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'hasToken' => false, // No OAuth token in multi_user_basic mode
'requesttoken' => \OCP\Util::callRegister(),
],
TemplateResponse::RENDER_AS_BLANK
);
} else {
// User has app password - show active status
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
$parameters = [
'userId' => $userId,
'serverStatus' => $serverStatus,
'session' => null, // No user session for app passwords
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'backgroundAccessGranted' => true, // App password grants background access
'serverUrl' => $this->client->getPublicServerUrl(),
'hasToken' => false, // No OAuth token
'hasBackgroundAccess' => true,
'backgroundSyncType' => $backgroundSyncType,
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
'authMode' => $authMode,
'supportsAppPasswords' => $supportsAppPasswords,
'requesttoken' => \OCP\Util::callRegister(),
];
return new TemplateResponse(
Application::APP_ID,
'settings/personal',
$parameters,
TemplateResponse::RENDER_AS_BLANK
);
}
}
// For OAuth modes, if no token or token is expired, show OAuth authorization UI
elseif (!$token || $this->tokenStorage->isExpired($token)) {
// If no token or token is expired, show OAuth authorization UI
if (!$token || $this->tokenStorage->isExpired($token)) {
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
return new TemplateResponse(
@@ -193,11 +117,6 @@ class Personal implements ISettings {
);
}
// Check background sync credential status
$hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
// Provide initial state for Vue.js frontend (if needed)
$this->initialState->provideInitialState('user-data', [
'userId' => $userId,
@@ -213,9 +132,6 @@ class Personal implements ISettings {
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false,
'serverUrl' => $this->client->getPublicServerUrl(),
'hasToken' => true,
'hasBackgroundAccess' => $hasBackgroundAccess,
'backgroundSyncType' => $backgroundSyncType,
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
];
return new TemplateResponse(
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "astrolabe",
"version": "0.4.4",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "astrolabe",
"version": "0.4.4",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nextcloud/axios": "^2.5.1",
@@ -20,12 +20,12 @@
"vue-material-design-icons": "^5.3.1"
},
"devDependencies": {
"@nextcloud/browserslist-config": "3.1.2",
"@nextcloud/eslint-config": "8.4.2",
"@nextcloud/stylelint-config": "3.1.1",
"@nextcloud/vite-config": "1.7.2",
"terser": "5.44.1",
"vite": "7.2.7"
"@nextcloud/browserslist-config": "^3.0.1",
"@nextcloud/eslint-config": "^8.4.2",
"@nextcloud/stylelint-config": "^3.1.0",
"@nextcloud/vite-config": "^1.5.2",
"terser": "^5.44.1",
"vite": "^7.1.3"
},
"engines": {
"node": "^22.0.0",
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "astrolabe",
"version": "0.6.0",
"version": "0.4.4",
"license": "AGPL-3.0-or-later",
"engines": {
"node": "^22.0.0",
@@ -29,11 +29,11 @@
"vue-material-design-icons": "^5.3.1"
},
"devDependencies": {
"@nextcloud/browserslist-config": "3.1.2",
"@nextcloud/eslint-config": "8.4.2",
"@nextcloud/stylelint-config": "3.1.1",
"@nextcloud/vite-config": "1.7.2",
"terser": "5.44.1",
"vite": "7.2.7"
"@nextcloud/browserslist-config": "^3.0.1",
"@nextcloud/eslint-config": "^8.4.2",
"@nextcloud/stylelint-config": "^3.1.0",
"@nextcloud/vite-config": "^1.5.2",
"terser": "^5.44.1",
"vite": "^7.1.3"
}
}
-1
View File
@@ -9,7 +9,6 @@
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import './styles/settings.css'
document.addEventListener('DOMContentLoaded', () => {
// Initialize search settings form
-124
View File
@@ -1,124 +0,0 @@
/**
* Personal settings page JavaScript for Astrolabe.
*
* Loads styles for the personal settings page and handles form interactions.
*/
import './styles/settings.css'
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', function() {
// Helper function to show error notifications
function showError(message) {
if (typeof OC !== 'undefined' && OC.Notification) {
OC.Notification.showTemporary(message, { type: 'error' })
} else {
alert(message)
}
}
function showSuccess(message) {
if (typeof OC !== 'undefined' && OC.Notification) {
OC.Notification.showTemporary(message, { type: 'success' })
} else {
alert(message)
}
}
// App password form with error handling
const appPasswordForm = document.getElementById('mcp-app-password-form')
if (appPasswordForm) {
appPasswordForm.addEventListener('submit', async function(e) {
e.preventDefault()
const submitButton = document.getElementById('mcp-save-app-password-button')
const originalText = submitButton.textContent
try {
submitButton.disabled = true
submitButton.textContent = t('astrolabe', 'Saving...')
const formData = new FormData(appPasswordForm)
const response = await fetch(appPasswordForm.action, {
method: 'POST',
body: formData,
})
const result = await response.json()
if (response.ok && result.success) {
showSuccess(t('astrolabe', 'Background sync access successfully provisioned!'))
setTimeout(() => window.location.reload(), 1000)
} else {
showError(result.error || t('astrolabe', 'Failed to save app password. Please check that it is valid.'))
}
} catch (error) {
console.error('App password provisioning error:', error)
showError(t('astrolabe', 'Unable to connect to server. Please check that the MCP server is running and try again.'))
} finally {
submitButton.disabled = false
submitButton.textContent = originalText
}
})
}
// Revoke form confirmation
const revokeForm = document.getElementById('mcp-revoke-form')
if (revokeForm) {
revokeForm.addEventListener('submit', function(e) {
if (!confirm(t('astrolabe', 'Are you sure you want to disable indexing? Your content will be removed from semantic search.'))) {
e.preventDefault()
}
})
}
// Disconnect form confirmation
const disconnectForm = document.getElementById('mcp-disconnect-form')
if (disconnectForm) {
disconnectForm.addEventListener('submit', function(e) {
if (!confirm(t('astrolabe', 'Are you sure you want to disconnect from Astrolabe? You will need to re-authorize to use semantic search.'))) {
e.preventDefault()
}
})
}
// Revoke background access form with error handling
const revokeBackgroundForm = document.getElementById('mcp-revoke-background-form')
if (revokeBackgroundForm) {
revokeBackgroundForm.addEventListener('submit', async function(e) {
e.preventDefault()
if (!confirm(t('astrolabe', 'Are you sure you want to revoke background sync access? The MCP server will no longer be able to access your Nextcloud data for background operations.'))) {
return
}
const submitButton = revokeBackgroundForm.querySelector('button[type="submit"]')
const originalText = submitButton.textContent
try {
submitButton.disabled = true
submitButton.textContent = t('astrolabe', 'Revoking...')
const formData = new FormData(revokeBackgroundForm)
const response = await fetch(revokeBackgroundForm.action, {
method: 'POST',
body: formData,
})
const result = await response.json()
if (response.ok && result.success) {
showSuccess(t('astrolabe', 'Background sync access revoked successfully.'))
setTimeout(() => window.location.reload(), 1000)
} else {
showError(result.error || t('astrolabe', 'Failed to revoke background sync access.'))
}
} catch (error) {
console.error('Revoke error:', error)
showError(t('astrolabe', 'Unable to connect to server. Your access may already be revoked, or the server may be down.'))
} finally {
submitButton.disabled = false
submitButton.textContent = originalText
}
})
}
})
-290
View File
@@ -1,290 +0,0 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Astrolabe settings styles
* Relies on Nextcloud's core .section class for layout
*/
/* Info tables */
.mcp-info-table {
width: 100%;
border-collapse: collapse;
margin: calc(var(--default-grid-baseline) * 3) 0;
}
.mcp-info-table tr {
border-bottom: 1px solid var(--color-border);
}
.mcp-info-table tr:last-child {
border-bottom: none;
}
.mcp-info-table td {
padding: calc(var(--default-grid-baseline) * 2) 0;
vertical-align: top;
}
.mcp-info-table td:first-child {
width: 200px;
color: var(--color-text-maxcontrast);
font-weight: 600;
padding-inline-end: calc(var(--default-grid-baseline) * 4);
}
.mcp-info-table td:last-child {
color: var(--color-main-text);
}
/* Status badges */
.badge {
display: inline-flex;
align-items: center;
gap: calc(var(--default-grid-baseline) * 1.5);
padding: calc(var(--default-grid-baseline) * 1.5) calc(var(--default-grid-baseline) * 3);
border-radius: calc(var(--border-radius-element) * 1.5);
font-size: 13px;
font-weight: 600;
}
.badge-success {
background: var(--color-success);
color: var(--color-success-text);
}
.badge-warning {
background: var(--color-warning);
color: var(--color-warning-text);
}
.badge-neutral {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
.badge-info {
background: var(--color-primary-element);
color: var(--color-primary-element-text);
}
/* Input groups */
.mcp-input-group {
display: flex;
gap: calc(var(--default-grid-baseline) * 2);
align-items: stretch;
margin-top: calc(var(--default-grid-baseline) * 2);
}
.mcp-input-group input[type='password'],
.mcp-input-group input[type='text'] {
flex: 1;
font-family: monospace;
}
/* Revoke/warning sections */
.mcp-revoke-section {
margin-top: calc(var(--default-grid-baseline) * 4);
padding: calc(var(--default-grid-baseline) * 4);
background: var(--color-warning);
border-radius: var(--border-radius-element);
border-inline-start: calc(var(--default-grid-baseline)) solid var(--color-warning-text);
}
/* Feature lists */
.mcp-feature-list {
list-style: none;
padding: 0;
margin: calc(var(--default-grid-baseline) * 3) 0;
}
.mcp-feature-list li {
display: flex;
gap: calc(var(--default-grid-baseline) * 3);
padding: calc(var(--default-grid-baseline) * 2) 0;
align-items: start;
}
.mcp-feature-list .icon {
flex-shrink: 0;
width: 24px;
height: 24px;
opacity: 0.7;
}
.mcp-feature-list div {
flex: 1;
}
.mcp-feature-list strong {
display: block;
font-weight: 600;
margin-bottom: calc(var(--default-grid-baseline));
}
.mcp-feature-list p {
margin: 0;
color: var(--color-text-maxcontrast);
}
/* Responsive tables */
@media (max-width: 768px) {
.mcp-info-table td:first-child,
.mcp-info-table td:last-child {
display: block;
width: 100%;
}
.mcp-info-table td:first-child {
padding-bottom: calc(var(--default-grid-baseline));
}
.mcp-info-table td:last-child {
padding-top: calc(var(--default-grid-baseline));
}
}
/* Admin settings forms */
.mcp-settings-form {
max-width: 600px;
}
.mcp-form-group {
margin-bottom: calc(var(--default-grid-baseline) * 5);
}
.mcp-form-group label {
display: block;
font-weight: 600;
margin-bottom: calc(var(--default-grid-baseline) * 2);
}
.mcp-range {
width: 100%;
margin-top: calc(var(--default-grid-baseline) * 2);
accent-color: var(--color-primary-element);
}
.mcp-form-actions {
display: flex;
align-items: center;
gap: calc(var(--default-grid-baseline) * 4);
margin-top: calc(var(--default-grid-baseline) * 6);
padding-top: calc(var(--default-grid-baseline) * 5);
border-top: 1px solid var(--color-border);
}
/* Webhook preset cards */
.mcp-preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: calc(var(--default-grid-baseline) * 4);
margin: calc(var(--default-grid-baseline) * 4) 0;
}
.mcp-preset-card {
background: var(--color-background-dark);
border-radius: var(--border-radius-container);
padding: calc(var(--default-grid-baseline) * 4);
border: 2px solid transparent;
transition: border-color var(--animation-slow), box-shadow var(--animation-slow);
}
.mcp-preset-card:hover {
border-color: var(--color-border-dark);
box-shadow: 0 2px 8px var(--color-box-shadow);
}
.mcp-preset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(var(--default-grid-baseline) * 3);
}
.mcp-preset-header h4 {
margin: 0;
font-weight: 600;
}
.mcp-preset-status {
padding: calc(var(--default-grid-baseline)) calc(var(--default-grid-baseline) * 2.5);
border-radius: calc(var(--border-radius-element) * 1.5);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.mcp-status-enabled {
background: var(--color-success);
color: var(--color-success-text);
}
.mcp-status-disabled {
background: var(--color-background-darker);
color: var(--color-text-maxcontrast);
}
.mcp-preset-description {
color: var(--color-text-maxcontrast);
margin-bottom: calc(var(--default-grid-baseline) * 3);
}
.mcp-preset-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: calc(var(--default-grid-baseline) * 3);
border-top: 1px solid var(--color-border);
margin-bottom: calc(var(--default-grid-baseline) * 3);
font-size: 12px;
color: var(--color-text-maxcontrast);
}
.mcp-preset-actions {
display: flex;
gap: calc(var(--default-grid-baseline) * 2);
}
.mcp-preset-toggle {
flex: 1;
padding: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 4);
border-radius: var(--border-radius-element);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all var(--animation-quick);
border: none;
}
.mcp-preset-toggle.primary {
background: var(--color-primary-element);
color: var(--color-primary-element-text);
}
.mcp-preset-toggle.primary:hover:not(:disabled) {
background: var(--color-primary-element-hover);
}
.mcp-preset-toggle.secondary {
background: var(--color-background-darker);
color: var(--color-main-text);
border: 1px solid var(--color-border);
}
.mcp-preset-toggle.secondary:hover:not(:disabled) {
background: var(--color-background-hover);
border-color: var(--color-border-dark);
}
.mcp-preset-toggle:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mcp-loading {
text-align: center;
padding: calc(var(--default-grid-baseline) * 5);
color: var(--color-text-maxcontrast);
font-style: italic;
}
-1
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
use OCP\Util;
Util::addScript(OCA\Astrolabe\AppInfo\Application::APP_ID, OCA\Astrolabe\AppInfo\Application::APP_ID . '-main');
Util::addStyle(OCA\Astrolabe\AppInfo\Application::APP_ID, OCA\Astrolabe\AppInfo\Application::APP_ID . '-main');
?>
+93 -2
View File
@@ -14,7 +14,7 @@
*/
script('astrolabe', 'astrolabe-adminSettings');
style('astrolabe', 'astrolabe-adminSettings');
style('astrolabe', 'astrolabe-settings');
?>
<div id="mcp-admin-settings" class="section">
@@ -22,7 +22,98 @@ style('astrolabe', 'astrolabe-adminSettings');
<div class="mcp-settings-info">
<p><?php p($l->t('Monitor and configure the semantic search service for your Nextcloud instance.')); ?></p>
<p><?php p($l->t('Use the "MCP Server Configuration" section above to configure the connection settings.')); ?></p>
</div>
<!-- Configuration Status -->
<div class="mcp-status-card">
<h3><?php p($l->t('Configuration')); ?></h3>
<table class="mcp-info-table">
<tr>
<td><strong><?php p($l->t('Service URL')); ?></strong></td>
<td>
<?php if (!empty($_['serverUrl'])): ?>
<code><?php p($_['serverUrl']); ?></code>
<?php else: ?>
<span class="error"><?php p($l->t('Not configured')); ?></span>
<?php endif; ?>
</td>
</tr>
<tr>
<td><strong><?php p($l->t('API Key')); ?></strong></td>
<td>
<?php if ($_['apiKeyConfigured']): ?>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Configured')); ?>
</span>
<?php else: ?>
<span class="badge badge-warning">
<span class="icon icon-alert"></span>
<?php p($l->t('Not configured')); ?>
</span>
<?php endif; ?>
</td>
</tr>
<tr>
<td><strong><?php p($l->t('OAuth Client ID')); ?></strong></td>
<td>
<?php if ($_['clientIdConfigured']): ?>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Configured')); ?>
</span>
<?php else: ?>
<span class="badge badge-warning">
<span class="icon icon-alert"></span>
<?php p($l->t('Not configured - OAuth will not work')); ?>
</span>
<?php endif; ?>
</td>
</tr>
<tr>
<td><strong><?php p($l->t('OAuth Client Secret')); ?></strong></td>
<td>
<?php if ($_['clientSecretConfigured']): ?>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Configured')); ?>
</span>
<?php else: ?>
<span class="badge badge-info">
<?php p($l->t('Optional - Uses PKCE fallback')); ?>
</span>
<?php endif; ?>
</td>
</tr>
</table>
<?php if (empty($_['serverUrl']) || !$_['apiKeyConfigured'] || !$_['clientIdConfigured']): ?>
<div class="notecard notecard-warning">
<p><strong><?php p($l->t('Configuration Required')); ?></strong></p>
<p><?php p($l->t('Add the following to your config.php:')); ?></p>
<pre><code>'mcp_server_url' => 'http://localhost:8000',
'mcp_server_api_key' => 'your-secret-api-key',
'astrolabe_client_id' => 'your-oauth-client-id',</code></pre>
<p class="mcp-help-text">
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server" target="_blank">
<?php p($l->t('See documentation for details')); ?>
</a>
</p>
</div>
<?php endif; ?>
<?php if (!$_['clientSecretConfigured']): ?>
<div class="notecard notecard-info">
<p><strong><?php p($l->t('Optional: Confidential OAuth Client')); ?></strong></p>
<p><?php p($l->t('To use refresh tokens for long-lived sessions, generate a client secret:')); ?></p>
<pre><code>openssl rand -hex 32</code></pre>
<p><?php p($l->t('Then add it to your config.php:')); ?></p>
<pre><code>'astrolabe_client_secret' => 'your-generated-secret',</code></pre>
<p class="mcp-help-text">
<?php p($l->t('Without a client secret, the system will use PKCE (public client) authentication. Both methods work, but confidential clients provide better security for long-lived sessions.')); ?>
</p>
</div>
<?php endif; ?>
</div>
<!-- Service Status -->
+2
View File
@@ -10,6 +10,8 @@
* @var string $_['server_url'] Configured server URL (optional)
* @var string $_['help_text'] Additional help text (optional)
*/
style('astrolabe', 'astrolabe-settings');
?>
<div class="mcp-settings-error">
+27 -17
View File
@@ -14,23 +14,29 @@
use OCP\Util;
Util::addStyle('astrolabe', 'astrolabe-personalSettings');
Util::addStyle('astrolabe', 'astrolabe-settings');
?>
<div class="section">
<h2><?php p($l->t('Astrolabe')); ?></h2>
<p><?php p($l->t('AI-powered semantic search across your Nextcloud content.')); ?></p>
</div>
<div id="mcp-personal-settings">
<div class="mcp-settings-info">
<p><?php p($l->t('AI-powered semantic search across your Nextcloud content.')); ?></p>
</div>
<?php if (isset($_['error_message'])): ?>
<div class="section">
<h2><?php p($l->t('Session Expired')); ?></h2>
<p><?php p($_['error_message']); ?></p>
</div>
<?php endif; ?>
<?php if (isset($_['error_message'])): ?>
<div class="mcp-status-card mcp-error">
<h3>
<span class="icon icon-error"></span>
<?php p($l->t('Session Expired')); ?>
</h3>
<p><?php p($_['error_message']); ?></p>
</div>
<?php endif; ?>
<div class="section">
<h2><?php p($l->t('Enable Semantic Search')); ?></h2>
<div class="mcp-status-card">
<h3>
<span class="icon icon-search"></span>
<?php p($l->t('Enable Semantic Search')); ?>
</h3>
<?php if (isset($_['has_expired']) && $_['has_expired']): ?>
<p>
@@ -90,13 +96,16 @@ Util::addStyle('astrolabe', 'astrolabe-personalSettings');
</a>
</div>
<p>
<p class="mcp-help-text" style="margin-top: 16px;">
<?php p($l->t('You can disable indexing at any time from this settings page.')); ?>
</p>
</div>
</div>
<div class="section">
<h2><?php p($l->t('About Astrolabe')); ?></h2>
<div class="mcp-status-card">
<h3>
<span class="icon icon-info"></span>
<?php p($l->t('About Astrolabe')); ?>
</h3>
<p>
<?php p($l->t('Astrolabe enables semantic search - finding content by meaning rather than exact keywords. Ask questions like "meeting notes from last week" or "recipes with chicken" to find relevant documents.')); ?>
@@ -114,4 +123,5 @@ Util::addStyle('astrolabe', 'astrolabe-personalSettings');
</a>
</li>
</ul>
</div>
</div>
+106 -134
View File
@@ -18,156 +18,102 @@
$urlGenerator = \OC::$server->getURLGenerator();
script('astrolabe', 'astrolabe-personalSettings');
style('astrolabe', 'astrolabe-personalSettings');
style('astrolabe', 'astrolabe-settings');
?>
<div class="section">
<div id="mcp-personal-settings" class="section">
<h2><?php p($l->t('Astrolabe')); ?></h2>
<p><?php p($l->t('AI-powered semantic search across your Nextcloud content. Find documents by meaning, not just keywords.')); ?></p>
</div>
<div class="section">
<h2><?php p($l->t('Service Status')); ?></h2>
<div class="mcp-settings-info">
<p><?php p($l->t('AI-powered semantic search across your Nextcloud content. Find documents by meaning, not just keywords.')); ?></p>
</div>
<!-- Service Status -->
<div class="mcp-status-card">
<h3><?php p($l->t('Service Status')); ?></h3>
<table class="mcp-info-table">
<tr>
<td><?php p($l->t('Service URL')); ?></td>
<td><strong><?php p($l->t('Service URL')); ?></strong></td>
<td><code><?php p($_['serverUrl']); ?></code></td>
</tr>
<tr>
<td><?php p($l->t('Version')); ?></td>
<td><strong><?php p($l->t('Version')); ?></strong></td>
<td><?php p($_['serverStatus']['version'] ?? 'Unknown'); ?></td>
</tr>
</table>
</div>
</div>
<div class="section">
<h2><?php p($l->t('Background Sync Access')); ?></h2>
<!-- Indexing Status -->
<div class="mcp-status-card">
<h3><?php p($l->t('Content Indexing')); ?></h3>
<table class="mcp-info-table">
<tr>
<td><strong><?php p($l->t('Status')); ?></strong></td>
<td>
<?php if ($_['backgroundAccessGranted']): ?>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Active')); ?>
</span>
<?php else: ?>
<span class="badge badge-neutral">
<?php p($l->t('Not Enabled')); ?>
</span>
<?php endif; ?>
</td>
</tr>
</table>
<?php if ($_['hasBackgroundAccess'] || $_['backgroundAccessGranted']): ?>
<!-- Already configured -->
<div class="mcp-background-status">
<p>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Active')); ?>
</span>
<?php if (!$_['backgroundAccessGranted']): ?>
<div class="mcp-grant-section">
<p class="mcp-help-text">
<?php p($l->t('Enable background indexing to use semantic search. Your Notes, Files, Calendar events, and Deck cards will be indexed so you can search by meaning.')); ?>
</p>
<a href="<?php p($_['serverUrl']); ?>/oauth/login?next=<?php p(urlencode($urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])))); ?>" class="button primary" id="mcp-grant-button">
<span class="icon icon-confirm"></span>
<?php p($l->t('Enable Semantic Search')); ?>
</a>
</div>
<?php endif; ?>
<?php if ($_['backgroundAccessGranted'] && isset($_['session']['background_access_details'])): ?>
<div class="mcp-background-details">
<h4><?php p($l->t('Indexing Details')); ?></h4>
<table class="mcp-info-table">
<tr>
<td><?php p($l->t('Credential Type')); ?></td>
<td>
<?php if ($_['backgroundSyncType'] === 'app_password'): ?>
<?php p($l->t('App Password')); ?>
<?php else: ?>
<?php p($l->t('OAuth Refresh Token')); ?>
<?php endif; ?>
</td>
<td><strong><?php p($l->t('Enabled Since')); ?></strong></td>
<td><?php p($_['session']['background_access_details']['provisioned_at'] ?? 'N/A'); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Indexed Content')); ?></strong></td>
<td><code style="font-size: 11px;"><?php p($_['session']['background_access_details']['scopes'] ?? 'N/A'); ?></code></td>
</tr>
<?php if ($_['backgroundSyncProvisionedAt']): ?>
<tr>
<td><?php p($l->t('Provisioned At')); ?></td>
<td><?php p(date('c', $_['backgroundSyncProvisionedAt'])); ?></td>
</tr>
<?php elseif (isset($_['session']['background_access_details']['provisioned_at'])): ?>
<tr>
<td><?php p($l->t('Provisioned At')); ?></td>
<td><?php p($_['session']['background_access_details']['provisioned_at']); ?></td>
</tr>
<?php endif; ?>
<?php if (isset($_['session']['background_access_details']['scopes'])): ?>
<tr>
<td><?php p($l->t('Indexed Content')); ?></td>
<td><code><?php p($_['session']['background_access_details']['scopes'] ?? 'N/A'); ?></code></td>
</tr>
<?php endif; ?>
</table>
<div class="mcp-revoke-section">
<?php if ($_['backgroundSyncType'] === 'app_password'): ?>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.deleteCredentials')); ?>" id="mcp-revoke-background-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<button type="submit" class="button warning" id="mcp-revoke-background-button">
<span class="icon icon-delete"></span>
<?php p($l->t('Revoke Access')); ?>
</button>
<p class="mcp-help-text">
<?php p($l->t('This will revoke background sync access. The MCP server will no longer be able to access your Nextcloud data for background operations.')); ?>
</p>
</form>
<?php else: ?>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.api.revokeAccess')); ?>" id="mcp-revoke-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<button type="submit" class="button warning" id="mcp-revoke-button">
<span class="icon icon-delete"></span>
<?php p($l->t('Disable Indexing')); ?>
</button>
<p class="mcp-help-text">
<?php p($l->t('This will stop background indexing and remove your content from semantic search. You can re-enable it at any time.')); ?>
</p>
</form>
<?php endif; ?>
</div>
</div>
<?php else: ?>
<!-- Not configured - show provisioning options -->
<p class="mcp-help-text">
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
</p>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
</p>
<a href="<?php p($_['serverUrl']); ?>/oauth/login?next=<?php p(urlencode($urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])))); ?>" class="button">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize via OAuth')); ?>
</a>
</div>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
</p>
<div class="mcp-app-password-steps">
<p><strong><?php p($l->t('Step 1:')); ?></strong>
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
<?php p($l->t('Generate app password in Security settings')); ?>
</a>
</p>
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.api.revokeAccess')); ?>" id="mcp-revoke-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<div class="mcp-input-group">
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<button type="submit" class="button warning" id="mcp-revoke-button">
<span class="icon icon-delete"></span>
<?php p($l->t('Disable Indexing')); ?>
</button>
<p class="mcp-help-text">
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
<?php p($l->t('This will stop background indexing and remove your content from semantic search. You can re-enable it at any time.')); ?>
</p>
</form>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php if (isset($_['session']['idp_profile'])): ?>
<div class="section">
<h2><?php p($l->t('Identity Provider Profile')); ?></h2>
<!-- Identity Provider Profile -->
<?php if (isset($_['session']['idp_profile'])): ?>
<div class="mcp-status-card">
<h3><?php p($l->t('Identity Provider Profile')); ?></h3>
<table class="mcp-info-table">
<?php foreach ($_['session']['idp_profile'] as $key => $value): ?>
<tr>
<td><?php p(ucfirst(str_replace('_', ' ', $key))); ?></td>
<td><strong><?php p(ucfirst(str_replace('_', ' ', $key))); ?></strong></td>
<td>
<?php if (is_array($value)): ?>
<?php p(implode(', ', $value)); ?>
@@ -178,29 +124,31 @@ style('astrolabe', 'astrolabe-personalSettings');
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($_['vectorSyncEnabled']): ?>
<div class="section">
<h2><?php p($l->t('Search Your Content')); ?></h2>
<!-- Semantic Search Features -->
<?php if ($_['vectorSyncEnabled']): ?>
<div class="mcp-status-card">
<h3><?php p($l->t('Search Your Content')); ?></h3>
<p><?php p($l->t('Use natural language to search across your Notes, Files, Calendar, and Deck cards. Ask questions like "meeting notes from last week" or "recipes with chicken".')); ?></p>
<a href="<?php p($urlGenerator->linkToRoute('astrolabe.page.index')); ?>" class="button primary">
<span class="icon icon-search"></span>
<?php p($l->t('Open Astrolabe')); ?>
</a>
</div>
<?php else: ?>
<div class="section">
<h2><?php p($l->t('Semantic Search')); ?></h2>
<p>
<?php p($l->t('Semantic search is not enabled on this server. Contact your administrator to enable this feature.')); ?>
</p>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<div class="mcp-status-card mcp-disabled">
<h3><?php p($l->t('Semantic Search')); ?></h3>
<p class="mcp-help-text">
<?php p($l->t('Semantic search is not enabled on this server. Contact your administrator to enable this feature.')); ?>
</p>
</div>
<?php endif; ?>
<div class="section">
<h2><?php p($l->t('Manage Connection')); ?></h2>
<!-- Connection Management -->
<div class="mcp-status-card">
<h3><?php p($l->t('Manage Connection')); ?></h3>
<p><?php p($l->t('You are connected to the Astrolabe service.')); ?></p>
<div class="mcp-revoke-section">
@@ -214,5 +162,29 @@ style('astrolabe', 'astrolabe-personalSettings');
<?php p($l->t('This will disconnect from the Astrolabe service. You will need to re-authorize to use semantic search features.')); ?>
</p>
</form>
</div>
</div>
</div>
<script>
// Confirm disable and disconnect actions
document.addEventListener('DOMContentLoaded', function() {
const revokeForm = document.getElementById('mcp-revoke-form');
if (revokeForm) {
revokeForm.addEventListener('submit', function(e) {
if (!confirm('<?php p($l->t('Are you sure you want to disable indexing? Your content will be removed from semantic search.')); ?>')) {
e.preventDefault();
}
});
}
const disconnectForm = document.getElementById('mcp-disconnect-form');
if (disconnectForm) {
disconnectForm.addEventListener('submit', function(e) {
if (!confirm('<?php p($l->t('Are you sure you want to disconnect from Astrolabe? You will need to re-authorize to use semantic search.')); ?>')) {
e.preventDefault();
}
});
}
});
</script>
@@ -1,6 +1,6 @@
{
"require-dev": {
"nextcloud/openapi-extractor": "v1.8.7"
"nextcloud/openapi-extractor": "v1.8.2"
},
"config": {
"platform": {
+10 -10
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "384d95db63f1a0aae08a0ae123ecf4bb",
"content-hash": "1f40f0a54fa934aa136ec78a01fdc61a",
"packages": [],
"packages-dev": [
{
@@ -82,16 +82,16 @@
},
{
"name": "nextcloud/openapi-extractor",
"version": "v1.8.7",
"version": "v1.8.2",
"source": {
"type": "git",
"url": "https://github.com/nextcloud-releases/openapi-extractor.git",
"reference": "230f61925c362779652b0038a1314ce5f931e853"
"reference": "aa4b6750b255460bec8d45406d33606863010d2e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nextcloud-releases/openapi-extractor/zipball/230f61925c362779652b0038a1314ce5f931e853",
"reference": "230f61925c362779652b0038a1314ce5f931e853",
"url": "https://api.github.com/repos/nextcloud-releases/openapi-extractor/zipball/aa4b6750b255460bec8d45406d33606863010d2e",
"reference": "aa4b6750b255460bec8d45406d33606863010d2e",
"shasum": ""
},
"require": {
@@ -102,9 +102,9 @@
"phpstan/phpdoc-parser": "^2.1"
},
"require-dev": {
"nextcloud/coding-standard": "^1.4.0",
"nextcloud/coding-standard": "^1.2",
"nextcloud/ocp": "dev-master",
"rector/rector": "^2.2.8"
"rector/rector": "^2.0"
},
"bin": [
"bin/generate-spec",
@@ -123,9 +123,9 @@
"description": "A tool for extracting OpenAPI specifications from Nextcloud source code",
"support": {
"issues": "https://github.com/nextcloud-releases/openapi-extractor/issues",
"source": "https://github.com/nextcloud-releases/openapi-extractor/tree/v1.8.7"
"source": "https://github.com/nextcloud-releases/openapi-extractor/tree/v1.8.2"
},
"time": "2025-12-02T09:52:06+00:00"
"time": "2025-08-26T06:28:24+00:00"
},
{
"name": "nikic/php-parser",
@@ -243,5 +243,5 @@
"platform-overrides": {
"php": "8.1"
},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
+2 -1
View File
@@ -3,5 +3,6 @@ import { createAppConfig } from '@nextcloud/vite-config'
export default createAppConfig({
main: 'src/main.js',
adminSettings: 'src/adminSettings.js',
personalSettings: 'src/personalSettings.js',
}, {
inlineCSS: { relativeCSSInjection: true },
})
Generated
+1 -1
View File
@@ -1988,7 +1988,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.59.0"
version = "0.56.2"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },