diff --git a/README.md b/README.md index c13968f..7d3a843 100644 --- a/README.md +++ b/README.md @@ -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 (`VECTOR_SYNC_ENABLED=false`) +> - Disabled by default (`ENABLE_SEMANTIC_SEARCH=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 diff --git a/docker-compose.yml b/docker-compose.yml index b6a06ba..b343f43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -135,15 +135,24 @@ services: 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 disabled (stateless pass-through mode) - - VECTOR_SYNC_ENABLED=false + - 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: diff --git a/docs/ADR-021-configuration-consolidation.md b/docs/ADR-021-configuration-consolidation.md new file mode 100644 index 0000000..e96aee0 --- /dev/null +++ b/docs/ADR-021-configuration-consolidation.md @@ -0,0 +1,391 @@ +# 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= +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= +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= +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= +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= +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= +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= +TOKEN_STORAGE_DB=/path/to/tokens.db +``` diff --git a/docs/configuration-migration-v2.md b/docs/configuration-migration-v2.md new file mode 100644 index 0000000..e7fde7b --- /dev/null +++ b/docs/configuration-migration-v2.md @@ -0,0 +1,564 @@ +# 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. diff --git a/docs/configuration.md b/docs/configuration.md index e451c3f..f29fbdd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,25 +2,82 @@ 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 -Create a `.env` file based on `env.sample`: +We provide mode-specific configuration templates for quick setup: ```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 authentication mode: +Then choose your deployment mode: -- [OAuth2/OIDC Configuration](#oauth2oidc-configuration) (Recommended) -- [Basic Authentication Configuration](#basic-authentication-legacy) +- [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 +## Deployment Mode Selection -OAuth2/OIDC is the recommended authentication mode for production deployments. +**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. ### Minimal Configuration (Auto-registration) @@ -28,6 +85,9 @@ OAuth2/OIDC is the recommended authentication mode for production deployments. # .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= @@ -41,6 +101,9 @@ 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 @@ -110,8 +173,50 @@ 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: @@ -126,7 +231,7 @@ No configuration needed! If neither `QDRANT_URL` nor `QDRANT_LOCATION` is set, t ```dotenv # No Qdrant configuration needed - defaults to :memory: -VECTOR_SYNC_ENABLED=true +ENABLE_SEMANTIC_SEARCH=true ``` **Pros:** @@ -145,7 +250,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 -VECTOR_SYNC_ENABLED=true +ENABLE_SEMANTIC_SEARCH=true ``` **Pros:** @@ -166,7 +271,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 -VECTOR_SYNC_ENABLED=true +ENABLE_SEMANTIC_SEARCH=true ``` **Pros:** @@ -283,13 +388,15 @@ Solutions: - Data corruption in Qdrant - Confusing error messages during indexing -### Vector Sync Configuration +### Background Indexing Configuration Control background indexing behavior: ```dotenv -# Vector sync settings (ADR-007) -VECTOR_SYNC_ENABLED=true # Enable background indexing +# Semantic search (ADR-007, ADR-021) +ENABLE_SEMANTIC_SEARCH=true # Enable background indexing + +# Tuning parameters (advanced - only modify if needed) 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) @@ -299,6 +406,8 @@ 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: @@ -369,11 +478,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 | `nextcloud_content` | Qdrant collection name | -| `VECTOR_SYNC_ENABLED` | ⚠️ Optional | `false` | Enable background vector indexing | +| `QDRANT_COLLECTION` | ⚠️ Optional | Auto-generated | Qdrant collection name | | `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 | @@ -383,6 +492,9 @@ 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: @@ -392,7 +504,7 @@ services: mcp: environment: - QDRANT_URL=http://qdrant:6333 - - VECTOR_SYNC_ENABLED=true + - ENABLE_SEMANTIC_SEARCH=true qdrant: image: qdrant/qdrant:latest @@ -545,6 +657,7 @@ 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 @@ -553,3 +666,4 @@ 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6fcc101..dbdb444 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4,6 +4,146 @@ 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= +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" diff --git a/env.sample b/env.sample index d9dc0cf..3469ced 100644 --- a/env.sample +++ b/env.sample @@ -1,203 +1,236 @@ -# Nextcloud Instance +# ============================================ +# 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_HOST= -# ===== 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 +# ============================================ +# SINGLE-USER BASICAUTH MODE +# ============================================ +# Simplest deployment - one user, credentials in environment +# Use for: Personal instances, local development, testing +# +# Required: 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 # ============================================ -# Document Processing Configuration +# DEPRECATED VARIABLES (Backward Compatibility) # ============================================ -# 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 +# 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 diff --git a/env.sample.oauth-advanced b/env.sample.oauth-advanced new file mode 100644 index 0000000..ca80559 --- /dev/null +++ b/env.sample.oauth-advanced @@ -0,0 +1,80 @@ +# ============================================ +# 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 diff --git a/env.sample.oauth-multi-user b/env.sample.oauth-multi-user new file mode 100644 index 0000000..f61ad18 --- /dev/null +++ b/env.sample.oauth-multi-user @@ -0,0 +1,77 @@ +# ============================================ +# 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 diff --git a/env.sample.single-user b/env.sample.single-user new file mode 100644 index 0000000..c62937d --- /dev/null +++ b/env.sample.single-user @@ -0,0 +1,37 @@ +# ============================================ +# 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 diff --git a/nextcloud_mcp_server/api/management.py b/nextcloud_mcp_server/api/management.py index 99a5fd4..0723822 100644 --- a/nextcloud_mcp_server/api/management.py +++ b/nextcloud_mcp_server/api/management.py @@ -182,14 +182,23 @@ async def get_server_status(request: Request) -> JSONResponse: # Calculate uptime uptime_seconds = int(time.time() - _server_start_time) - # Determine auth mode - nextcloud_username = os.getenv("NEXTCLOUD_USERNAME") - nextcloud_password = os.getenv("NEXTCLOUD_PASSWORD") + # Determine auth mode using proper mode detection + from nextcloud_mcp_server.config_validators import AuthMode, detect_auth_mode - if nextcloud_username and nextcloud_password: - auth_mode = "basic" - else: + 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: + auth_mode = "basic" + elif mode == AuthMode.SMITHERY_STATELESS: + auth_mode = "smithery" + else: + auth_mode = "unknown" response_data = { "version": __version__, @@ -199,6 +208,10 @@ 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 diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index f8a0091..5895bab 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -1,6 +1,7 @@ import logging import os import time +import traceback from collections.abc import AsyncIterator from contextlib import AsyncExitStack, asynccontextmanager from contextvars import ContextVar @@ -1065,6 +1066,75 @@ 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 + + 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): logger.info("Configuring MCP server for OAuth mode") @@ -1328,19 +1398,66 @@ 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 - share storage with browser_app for webhook management + # BasicAuth mode - initialize storage 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") 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]}...)" + ) + # 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" + ) logger.info( "Storage shared with browser_app for webhook management" ) @@ -1351,12 +1468,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = import anyio as anyio_module # Re-use settings from outer scope (already validated) - - # 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") + # Note: enable_offline_access_for_sync, encryption_key, and refresh_token_storage + # are already defined in outer scope before mode split # Multi-user BasicAuth uses OAuth-style background sync (with app passwords) # So skip single-user BasicAuth vector sync if in multi-user mode @@ -1465,9 +1578,7 @@ 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 enable_offline_access_for_sync - and refresh_token_storage - and encryption_key + and settings.enable_offline_access ): # OAuth mode with offline access - multi-user sync # Also used for multi-user BasicAuth mode (client auth is BasicAuth, background sync uses app passwords) @@ -1491,137 +1602,167 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = f"{nextcloud_host_for_sync}/.well-known/openid-configuration", ) - # 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 + # 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() 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, get OIDC credentials from environment + # For multi-user BasicAuth mode, use pre-obtained credentials from outer scope if not sync_client_id or not sync_client_secret: - sync_client_id = settings.oidc_client_id - sync_client_secret = settings.oidc_client_secret - - if not sync_client_id or not sync_client_secret: - logger.error( - "Cannot start OAuth vector sync: client credentials not found in oauth_context" - ) - raise ValueError("OAuth client credentials required for vector sync") - - # 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, - ) - - # 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, + 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.info( - f"Background sync tasks started: 1 user manager + " - f"{settings.vector_sync_processor_workers} processors" + # 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 ) - # Run MCP session manager and yield + # 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) + await tg.start( + user_manager_task, + send_stream, + 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, + ) + + # 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." + ) + # Just run MCP session manager without vector sync 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 + yield + 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 enable_offline_access_for_sync: + if oauth_enabled and not settings.enable_offline_access: logger.warning( "Vector sync enabled but ENABLE_OFFLINE_ACCESS=false - " "vector sync requires offline access in OAuth mode" @@ -1630,7 +1771,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 encryption_key: + elif oauth_enabled and not os.getenv("TOKEN_ENCRYPTION_KEY"): logger.warning( "Vector sync enabled but TOKEN_ENCRYPTION_KEY not set" ) @@ -1693,12 +1834,20 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = is_ready = False # Check authentication configuration - if oauth_enabled: - # OAuth mode - just verify we got this far (token_verifier initialized in lifespan) + # 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 + ): checks["auth_mode"] = "oauth" checks["auth_configured"] = "ok" - else: - # BasicAuth mode - verify credentials are set + 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: username = os.getenv("NEXTCLOUD_USERNAME") password = os.getenv("NEXTCLOUD_PASSWORD") if username and password: @@ -1708,6 +1857,9 @@ 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 @@ -1789,8 +1941,12 @@ 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 (OAuth mode only) - if oauth_enabled: + # 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: from nextcloud_mcp_server.api.management import ( create_webhook, delete_webhook, diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 11e2268..f0120ae 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -163,6 +163,12 @@ 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 @@ -351,13 +357,131 @@ 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"), @@ -378,9 +502,7 @@ def get_settings() -> Settings: enable_token_exchange=( os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true" ), - enable_offline_access=( - os.getenv("ENABLE_OFFLINE_ACCESS", "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" @@ -391,9 +513,7 @@ 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=( - os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true" - ), + vector_sync_enabled=enable_semantic_search, # Smart dependency resolution vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")), vector_sync_processor_workers=int( os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3") diff --git a/nextcloud_mcp_server/config_validators.py b/nextcloud_mcp_server/config_validators.py index e6343fc..db6236c 100644 --- a/nextcloud_mcp_server/config_validators.py +++ b/nextcloud_mcp_server/config_validators.py @@ -105,15 +105,13 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { ], conditional={ "enable_offline_access": [ - "oidc_client_id", - "oidc_client_secret", + # OAuth credentials validated separately (lines 397-406) with clearer error message "token_encryption_key", "token_storage_db", ], - "vector_sync_enabled": [ - # Requires offline access for background sync - "enable_offline_access", - ], + # 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. " @@ -152,9 +150,9 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { "token_encryption_key", "token_storage_db", ], - "vector_sync_enabled": [ - "enable_offline_access", # Background sync requires refresh tokens - ], + # 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). " @@ -192,9 +190,9 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { "token_encryption_key", "token_storage_db", ], - "vector_sync_enabled": [ - "enable_offline_access", - ], + # 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. " @@ -225,7 +223,8 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { def detect_auth_mode(settings: Settings) -> AuthMode: """Detect authentication mode from configuration. - Mode detection priority (most specific to most general): + 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 @@ -237,12 +236,43 @@ def detect_auth_mode(settings: Settings) -> AuthMode: 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 - import os - if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true": return AuthMode.SMITHERY_STATELESS @@ -364,22 +394,20 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]: ) if mode == AuthMode.MULTI_USER_BASIC: - # Validate that if offline access enabled, we have OAuth credentials + # 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: - errors.append( - f"[{mode.value}] NEXTCLOUD_OIDC_CLIENT_ID and " - "NEXTCLOUD_OIDC_CLIENT_SECRET are required when " - "ENABLE_OFFLINE_ACCESS is enabled (for app password retrieval)" + logger.info( + f"[{mode.value}] OAuth credentials not configured. " + "Will attempt Dynamic Client Registration (DCR) at startup " + "(required for app password retrieval via Astrolabe)." ) - # Validate vector sync requirements - if settings.vector_sync_enabled and not settings.enable_offline_access: - errors.append( - f"[{mode.value}] ENABLE_OFFLINE_ACCESS must be enabled when " - "VECTOR_SYNC_ENABLED is true (background sync requires " - "app passwords or refresh tokens)" - ) + # 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 diff --git a/tests/conftest.py b/tests/conftest.py index 29daa91..1c24dec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2320,7 +2320,10 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient): }, } - logger.info("Creating test users for multi-user OAuth testing...") + 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) created_users = [] try: @@ -3267,7 +3270,7 @@ async def configure_astrolabe_for_mcp_server(nc_client): ) # Configure MCP server URLs in Nextcloud system config - subprocess.run( + result = subprocess.run( [ "docker", "compose", @@ -3281,11 +3284,45 @@ async def configure_astrolabe_for_mcp_server(nc_client): "--value", mcp_server_internal_url, ], - check=True, capture_output=True, + text=True, ) - subprocess.run( + 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", @@ -3299,11 +3336,18 @@ async def configure_astrolabe_for_mcp_server(nc_client): "--value", mcp_server_public_url, ], - check=True, capture_output=True, + text=True, ) - logger.info("✓ MCP server URLs configured") + 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: diff --git a/tests/integration/test_astrolabe_multi_user_background_sync.py b/tests/integration/test_astrolabe_multi_user_background_sync.py new file mode 100644 index 0000000..5513d50 --- /dev/null +++ b/tests/integration/test_astrolabe_multi_user_background_sync.py @@ -0,0 +1,561 @@ +"""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 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!" + ) diff --git a/tests/integration/test_astrolabe_settings_buttons.py b/tests/integration/test_astrolabe_settings_buttons.py new file mode 100644 index 0000000..e93ea7f --- /dev/null +++ b/tests/integration/test_astrolabe_settings_buttons.py @@ -0,0 +1,91 @@ +"""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 diff --git a/tests/unit/test_config_validators.py b/tests/unit/test_config_validators.py index aa3f546..824135a 100644 --- a/tests/unit/test_config_validators.py +++ b/tests/unit/test_config_validators.py @@ -281,7 +281,7 @@ class TestMultiUserBasicValidation: assert any("nextcloud_password" in err.lower() for err in errors) def test_offline_access_missing_oauth_credentials(self): - """Test error when offline access enabled but OAuth credentials missing.""" + """Test that offline access works without OAuth credentials (will use DCR).""" settings = Settings( nextcloud_host="http://localhost", enable_multi_user_basic_auth=True, @@ -293,7 +293,8 @@ class TestMultiUserBasicValidation: mode, errors = validate_configuration(settings) assert mode == AuthMode.MULTI_USER_BASIC - assert any("oidc_client_id" in err.lower() for err in errors) + # 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.""" @@ -311,20 +312,35 @@ class TestMultiUserBasicValidation: assert mode == AuthMode.MULTI_USER_BASIC assert any("token_encryption_key" in err.lower() for err in errors) - def test_vector_sync_requires_offline_access(self): - """Test error when vector sync enabled but offline access disabled.""" - settings = Settings( - nextcloud_host="http://localhost", - enable_multi_user_basic_auth=True, - vector_sync_enabled=True, - qdrant_location=":memory:", - ollama_base_url="http://ollama:11434", - ) + 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 - mode, errors = validate_configuration(settings) + settings = get_settings() + mode, errors = validate_configuration(settings) - assert mode == AuthMode.MULTI_USER_BASIC - assert any("enable_offline_access" in err.lower() for err in errors) + 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: @@ -396,19 +412,33 @@ class TestOAuthSingleAudienceValidation: assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE assert any("token_encryption_key" in err.lower() for err in errors) - def test_vector_sync_requires_offline_access(self): - """Test error when vector sync enabled but offline access disabled.""" - settings = Settings( - nextcloud_host="http://localhost", - vector_sync_enabled=True, - qdrant_location=":memory:", - ollama_base_url="http://ollama:11434", - ) + 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 - mode, errors = validate_configuration(settings) + settings = get_settings() + mode, errors = validate_configuration(settings) - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE - assert any("enable_offline_access" in err.lower() for err in errors) + 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: @@ -576,3 +606,387 @@ class TestEdgeCases: # 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 diff --git a/third_party/astrolabe/appinfo/routes.php b/third_party/astrolabe/appinfo/routes.php index f9fb490..37ca97f 100644 --- a/third_party/astrolabe/appinfo/routes.php +++ b/third_party/astrolabe/appinfo/routes.php @@ -34,6 +34,28 @@ 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', diff --git a/third_party/astrolabe/lib/AppInfo/Application.php b/third_party/astrolabe/lib/AppInfo/Application.php index 38e7f3e..75aed93 100644 --- a/third_party/astrolabe/lib/AppInfo/Application.php +++ b/third_party/astrolabe/lib/AppInfo/Application.php @@ -4,11 +4,15 @@ 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'; @@ -21,6 +25,19 @@ 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 { diff --git a/third_party/astrolabe/lib/Controller/ApiController.php b/third_party/astrolabe/lib/Controller/ApiController.php index 6189b8a..af45f8c 100644 --- a/third_party/astrolabe/lib/Controller/ApiController.php +++ b/third_party/astrolabe/lib/Controller/ApiController.php @@ -97,6 +97,12 @@ 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 } diff --git a/third_party/astrolabe/lib/Controller/CredentialsController.php b/third_party/astrolabe/lib/Controller/CredentialsController.php new file mode 100644 index 0000000..c081886 --- /dev/null +++ b/third_party/astrolabe/lib/Controller/CredentialsController.php @@ -0,0 +1,258 @@ +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); + } + } +} diff --git a/third_party/astrolabe/lib/Listener/AstrolabeAdminSettingsListener.php b/third_party/astrolabe/lib/Listener/AstrolabeAdminSettingsListener.php new file mode 100644 index 0000000..8c84c7c --- /dev/null +++ b/third_party/astrolabe/lib/Listener/AstrolabeAdminSettingsListener.php @@ -0,0 +1,101 @@ + + */ +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(); + } +} diff --git a/third_party/astrolabe/lib/Service/McpTokenStorage.php b/third_party/astrolabe/lib/Service/McpTokenStorage.php index df3242c..62cef18 100644 --- a/third_party/astrolabe/lib/Service/McpTokenStorage.php +++ b/third_party/astrolabe/lib/Service/McpTokenStorage.php @@ -202,4 +202,176 @@ 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; + } } diff --git a/third_party/astrolabe/lib/Settings/AstrolabeAdminSettings.php b/third_party/astrolabe/lib/Settings/AstrolabeAdminSettings.php new file mode 100644 index 0000000..f549f5d --- /dev/null +++ b/third_party/astrolabe/lib/Settings/AstrolabeAdminSettings.php @@ -0,0 +1,64 @@ + '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' => '', + ], + ], + ]; + } +} diff --git a/third_party/astrolabe/lib/Settings/Personal.php b/third_party/astrolabe/lib/Settings/Personal.php index 3632faa..577f1e8 100644 --- a/third_party/astrolabe/lib/Settings/Personal.php +++ b/third_party/astrolabe/lib/Settings/Personal.php @@ -55,11 +55,87 @@ 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); - // If no token or token is expired, show OAuth authorization UI - if (!$token || $this->tokenStorage->isExpired($token)) { + // 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)) { $oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth'); return new TemplateResponse( @@ -117,6 +193,11 @@ 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, @@ -132,6 +213,9 @@ 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( diff --git a/third_party/astrolabe/src/adminSettings.js b/third_party/astrolabe/src/adminSettings.js index 67aa6fc..84cde7f 100644 --- a/third_party/astrolabe/src/adminSettings.js +++ b/third_party/astrolabe/src/adminSettings.js @@ -9,6 +9,7 @@ import { generateUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' +import './styles/settings.css' document.addEventListener('DOMContentLoaded', () => { // Initialize search settings form diff --git a/third_party/astrolabe/src/personalSettings.js b/third_party/astrolabe/src/personalSettings.js new file mode 100644 index 0000000..12c1427 --- /dev/null +++ b/third_party/astrolabe/src/personalSettings.js @@ -0,0 +1,124 @@ +/** + * 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 + } + }) + } +}) diff --git a/third_party/astrolabe/src/styles/settings.css b/third_party/astrolabe/src/styles/settings.css new file mode 100644 index 0000000..69a2f71 --- /dev/null +++ b/third_party/astrolabe/src/styles/settings.css @@ -0,0 +1,290 @@ +/** + * 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; +} diff --git a/third_party/astrolabe/templates/index.php b/third_party/astrolabe/templates/index.php index af41b7c..a2ca69f 100644 --- a/third_party/astrolabe/templates/index.php +++ b/third_party/astrolabe/templates/index.php @@ -5,6 +5,7 @@ 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'); ?> diff --git a/third_party/astrolabe/templates/settings/admin.php b/third_party/astrolabe/templates/settings/admin.php index 88c09b9..64d4625 100644 --- a/third_party/astrolabe/templates/settings/admin.php +++ b/third_party/astrolabe/templates/settings/admin.php @@ -14,7 +14,7 @@ */ script('astrolabe', 'astrolabe-adminSettings'); -style('astrolabe', 'astrolabe-settings'); +style('astrolabe', 'astrolabe-adminSettings'); ?>
@@ -22,98 +22,7 @@ style('astrolabe', 'astrolabe-settings');

t('Monitor and configure the semantic search service for your Nextcloud instance.')); ?>

-
- - -
-

t('Configuration')); ?>

- - - - - - - - - - - - - - - - - -
t('Service URL')); ?> - - - - t('Not configured')); ?> - -
t('API Key')); ?> - - - - t('Configured')); ?> - - - - - t('Not configured')); ?> - - -
t('OAuth Client ID')); ?> - - - - t('Configured')); ?> - - - - - t('Not configured - OAuth will not work')); ?> - - -
t('OAuth Client Secret')); ?> - - - - t('Configured')); ?> - - - - t('Optional - Uses PKCE fallback')); ?> - - -
- - -
-

t('Configuration Required')); ?>

-

t('Add the following to your config.php:')); ?>

-
'mcp_server_url' => 'http://localhost:8000',
-'mcp_server_api_key' => 'your-secret-api-key',
-'astrolabe_client_id' => 'your-oauth-client-id',
-

- - t('See documentation for details')); ?> - -

-
- - - -
-

t('Optional: Confidential OAuth Client')); ?>

-

t('To use refresh tokens for long-lived sessions, generate a client secret:')); ?>

-
openssl rand -hex 32
-

t('Then add it to your config.php:')); ?>

-
'astrolabe_client_secret' => 'your-generated-secret',
-

- 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.')); ?> -

-
- +

t('Use the "MCP Server Configuration" section above to configure the connection settings.')); ?>

diff --git a/third_party/astrolabe/templates/settings/error.php b/third_party/astrolabe/templates/settings/error.php index 39533ae..6f31e49 100644 --- a/third_party/astrolabe/templates/settings/error.php +++ b/third_party/astrolabe/templates/settings/error.php @@ -10,8 +10,6 @@ * @var string $_['server_url'] Configured server URL (optional) * @var string $_['help_text'] Additional help text (optional) */ - -style('astrolabe', 'astrolabe-settings'); ?>
diff --git a/third_party/astrolabe/templates/settings/oauth-required.php b/third_party/astrolabe/templates/settings/oauth-required.php index 9ce8fdf..0443d50 100644 --- a/third_party/astrolabe/templates/settings/oauth-required.php +++ b/third_party/astrolabe/templates/settings/oauth-required.php @@ -14,29 +14,23 @@ use OCP\Util; -Util::addStyle('astrolabe', 'astrolabe-settings'); +Util::addStyle('astrolabe', 'astrolabe-personalSettings'); ?> -
-
-

t('AI-powered semantic search across your Nextcloud content.')); ?>

-
+
+

t('Astrolabe')); ?>

+

t('AI-powered semantic search across your Nextcloud content.')); ?>

+
- -
-

- - t('Session Expired')); ?> -

-

-
- + +
+

t('Session Expired')); ?>

+

+
+ -
-

- - t('Enable Semantic Search')); ?> -

+
+

t('Enable Semantic Search')); ?>

@@ -96,16 +90,13 @@ Util::addStyle('astrolabe', 'astrolabe-settings');

-

+

t('You can disable indexing at any time from this settings page.')); ?>

-
+
-
-

- - t('About Astrolabe')); ?> -

+
+

t('About Astrolabe')); ?>

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.')); ?> @@ -123,5 +114,4 @@ Util::addStyle('astrolabe', 'astrolabe-settings'); -

diff --git a/third_party/astrolabe/templates/settings/personal.php b/third_party/astrolabe/templates/settings/personal.php index 5abfe57..f4738be 100644 --- a/third_party/astrolabe/templates/settings/personal.php +++ b/third_party/astrolabe/templates/settings/personal.php @@ -18,102 +18,156 @@ $urlGenerator = \OC::$server->getURLGenerator(); script('astrolabe', 'astrolabe-personalSettings'); -style('astrolabe', 'astrolabe-settings'); +style('astrolabe', 'astrolabe-personalSettings'); ?> -
+

t('Astrolabe')); ?>

+

t('AI-powered semantic search across your Nextcloud content. Find documents by meaning, not just keywords.')); ?>

+
-
-

t('AI-powered semantic search across your Nextcloud content. Find documents by meaning, not just keywords.')); ?>

-
- - -
-

t('Service Status')); ?>

+
+

t('Service Status')); ?>

- + - +
t('Service URL')); ?>t('Service URL')); ?>
t('Version')); ?>t('Version')); ?>
-
+
- -
-

t('Content Indexing')); ?>

- - - - - -
t('Status')); ?> - - - - t('Active')); ?> - - - - t('Not Enabled')); ?> - - -
+
+

t('Background Sync Access')); ?>

- -
-

- 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.')); ?> + + +

+

+ + + t('Active')); ?> +

- - - t('Enable Semantic Search')); ?> - -
- - - -
-

t('Indexing Details')); ?>

- - - - - - + + + + + + + + + + + + + + + + + + +
t('Enabled Since')); ?>
t('Indexed Content')); ?>t('Credential Type')); ?> + + t('App Password')); ?> + + t('OAuth Refresh Token')); ?> + +
t('Provisioned At')); ?>
t('Provisioned At')); ?>
t('Indexed Content')); ?>
-
+ + + + +

+ t('This will revoke background sync access. The MCP server will no longer be able to access your Nextcloud data for background operations.')); ?> +

+
+ +
+ + +

+ t('This will stop background indexing and remove your content from semantic search. You can re-enable it at any time.')); ?> +

+
+ +
+
+ + +

+ t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?> +

+ +
+

t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?>

+

+ t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?> +

+ + + t('Authorize via OAuth')); ?> + +
+ +
+

t('Option 2: App Password (Works Today - Recommended)')); ?>

+

+ t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?> +

+ +
+

t('Step 1:')); ?> + + t('Generate app password in Security settings')); ?> + +

+ +

t('Step 2:')); ?> t('Enter the app password below:')); ?>

+ +
- +
+ + +

- t('This will stop background indexing and remove your content from semantic search. You can re-enable it at any time.')); ?> + t('The app password will be validated and securely encrypted before storage.')); ?>

-
+
- - -
-

t('Identity Provider Profile')); ?>

+ +
+

t('Identity Provider Profile')); ?>

$value): ?> - +
@@ -124,31 +178,29 @@ style('astrolabe', 'astrolabe-settings');
-
- +
+ - - -
-

t('Search Your Content')); ?>

+ +
+

t('Search Your Content')); ?>

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".')); ?>

t('Open Astrolabe')); ?> -
- -
-

t('Semantic Search')); ?>

-

- t('Semantic search is not enabled on this server. Contact your administrator to enable this feature.')); ?> -

-
- +
+ +
+

t('Semantic Search')); ?>

+

+ t('Semantic search is not enabled on this server. Contact your administrator to enable this feature.')); ?> +

+
+ - -
-

t('Manage Connection')); ?>

+
+

t('Manage Connection')); ?>

t('You are connected to the Astrolabe service.')); ?>

@@ -162,29 +214,5 @@ style('astrolabe', 'astrolabe-settings'); t('This will disconnect from the Astrolabe service. You will need to re-authorize to use semantic search features.')); ?>

-
- - diff --git a/third_party/astrolabe/vite.config.js b/third_party/astrolabe/vite.config.js index 078602d..e7db461 100644 --- a/third_party/astrolabe/vite.config.js +++ b/third_party/astrolabe/vite.config.js @@ -3,6 +3,5 @@ import { createAppConfig } from '@nextcloud/vite-config' export default createAppConfig({ main: 'src/main.js', adminSettings: 'src/adminSettings.js', -}, { - inlineCSS: { relativeCSSInjection: true }, + personalSettings: 'src/personalSettings.js', })