From 1a5bb10cd0cfc96a1f119e26259106a2136951f8 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 21 Dec 2025 20:36:36 +0100 Subject: [PATCH 1/4] feat(config): consolidate configuration with smart dependency resolution (ADR-021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplifies configuration by consolidating overlapping settings and adding automatic dependency resolution. This makes semantic search configuration significantly easier for users while maintaining 100% backward compatibility. ## Key Changes ### Variable Renaming (Backward Compatible) - `VECTOR_SYNC_ENABLED` → `ENABLE_SEMANTIC_SEARCH` (old name still works) - `ENABLE_OFFLINE_ACCESS` → `ENABLE_BACKGROUND_OPERATIONS` (old name still works) - Deprecation warnings logged when old names used - Old names will be removed in v1.0.0 ### Smart Dependency Resolution - `ENABLE_SEMANTIC_SEARCH` automatically enables background operations in multi-user modes - No need to set both `ENABLE_OFFLINE_ACCESS` and `VECTOR_SYNC_ENABLED` anymore - Single-user mode doesn't auto-enable background ops (not needed) ### Explicit Mode Selection (Optional) - New `MCP_DEPLOYMENT_MODE` environment variable - Valid values: single_user_basic, multi_user_basic, oauth_single_audience, oauth_token_exchange, smithery - Removes ambiguity about which deployment mode is active - Falls back to auto-detection if not set (existing behavior) ### Configuration Templates - Reorganized `env.sample` by deployment mode with clear sections - Added mode-specific quick-start templates: - `env.sample.single-user` - Simplest configuration - `env.sample.oauth-multi-user` - Recommended multi-user - `env.sample.oauth-advanced` - Token exchange mode ## Implementation Details ### Files Modified - `nextcloud_mcp_server/config.py` - Smart dependency resolution helpers - `nextcloud_mcp_server/config_validators.py` - Simplified validation, explicit mode - `tests/unit/test_config_validators.py` - 19 new tests (60 total, all passing) - `env.sample` - Reorganized by deployment mode - `docs/configuration.md` - Complete rewrite with consolidated approach - `docs/troubleshooting.md` - New consolidation troubleshooting section - `README.md` - Updated variable references ### New Files - `docs/ADR-021-configuration-consolidation.md` - Architecture decision record - `docs/configuration-migration-v2.md` - Comprehensive migration guide - `env.sample.single-user` - Single-user quick-start template - `env.sample.oauth-multi-user` - OAuth multi-user quick-start template - `env.sample.oauth-advanced` - Token exchange quick-start template ## User Impact ### Before (Confusing) ```bash ENABLE_OFFLINE_ACCESS=true # Why both? VECTOR_SYNC_ENABLED=true # What's the relationship? ``` ### After (Simplified) ```bash MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional) ENABLE_SEMANTIC_SEARCH=true # Auto-enables background ops! ``` ### Benefits - 📉 2 fewer variables to understand for semantic search - 📋 Clear intent ("I want semantic search") - 🎯 Explicit mode declaration available - 🔄 100% backward compatible - ✅ All 265 unit tests passing ## Testing - All 60 config validation tests passing - 10 new tests for configuration consolidation - 9 new tests for explicit mode selection - Full unit test suite: 265 tests passing - Backward compatibility verified ## Migration Users can migrate at their own pace. Old variable names continue working with deprecation warnings. See docs/configuration-migration-v2.md for detailed migration instructions. Related: ADR-021 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 +- docs/ADR-021-configuration-consolidation.md | 391 ++++++++++++++ docs/configuration-migration-v2.md | 564 ++++++++++++++++++++ docs/configuration.md | 144 ++++- docs/troubleshooting.md | 140 +++++ env.sample | 417 ++++++++------- env.sample.oauth-advanced | 80 +++ env.sample.oauth-multi-user | 77 +++ env.sample.single-user | 37 ++ nextcloud_mcp_server/config.py | 132 ++++- nextcloud_mcp_server/config_validators.py | 73 ++- tests/unit/test_config_validators.py | 459 +++++++++++++++- 12 files changed, 2257 insertions(+), 259 deletions(-) create mode 100644 docs/ADR-021-configuration-consolidation.md create mode 100644 docs/configuration-migration-v2.md create mode 100644 env.sample.oauth-advanced create mode 100644 env.sample.oauth-multi-user create mode 100644 env.sample.single-user 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/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/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..0fbc872 100644 --- a/nextcloud_mcp_server/config_validators.py +++ b/nextcloud_mcp_server/config_validators.py @@ -110,10 +110,9 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { "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 +151,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 +191,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 +224,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 +237,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 +395,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 + # Validate that if background operations enabled, we have OAuth credentials 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)" + "ENABLE_BACKGROUND_OPERATIONS (or deprecated ENABLE_OFFLINE_ACCESS) " + "is enabled (for app password retrieval)" ) - # 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/unit/test_config_validators.py b/tests/unit/test_config_validators.py index aa3f546..07b50ee 100644 --- a/tests/unit/test_config_validators.py +++ b/tests/unit/test_config_validators.py @@ -311,20 +311,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 +411,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 +605,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 From b29325821026c06106b271e20ecf78602c17444c Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 22 Dec 2025 19:32:06 +0100 Subject: [PATCH 2/4] test(astrolabe): fix app password extraction in multi-user background sync test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the Playwright-based integration test that verifies multi-user app password provisioning for background sync in Astrolabe. **Root Cause:** The test was failing to extract the generated app password from Nextcloud's "New app password" dialog due to overly specific CSS selectors that didn't match the actual DOM structure. **Changes:** - Enhanced network response logging to capture HTTP status codes - Simplified app password extraction logic: * Wait for dialog heading using text selector * Iterate through ALL text inputs on page * Find password by pattern: contains dashes and length > 20 * Validate extracted password against expected format - Added format validation with regex before returning password - Added detailed debug logging for each extraction step - Improved error messages with screenshot paths **Testing:** Test now successfully completes for both alice and bob test users: - Logs in to Nextcloud - Generates app password in Security settings - Extracts password from dialog - Navigates to Astrolabe settings - Enters and saves app password - Verifies "Active" badge appears - Confirms credentials stored in database 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- ...st_astrolabe_multi_user_background_sync.py | 561 ++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100644 tests/integration/test_astrolabe_multi_user_background_sync.py 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!" + ) From 65c3f099fafcb6770cb8d4edecc53700f04092c6 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 22 Dec 2025 19:39:13 +0100 Subject: [PATCH 3/4] feat(astrolabe): implement app password provisioning for multi-user background sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds complete app password provisioning workflow for multi-user BasicAuth deployments, allowing users to independently enable background sync by generating and storing Nextcloud app passwords. **New Components:** Backend (PHP): - CredentialsController: Validates and stores app passwords * Validates app password format and authenticity via OCS API * Stores encrypted passwords in oc_preferences * Provides status and credential management endpoints - AstrolabeAdminSettings: Admin configuration page for MCP server URL - AstrolabeAdminSettingsListener: Event listener for admin section - Updated McpTokenStorage: Added background sync credential methods Frontend: - personalSettings.js: Form handling for app password entry * AJAX submission with error handling * Shows success/error notifications * Triggers page reload after successful save - settings.css: Styling for settings pages - Updated personal.php template: Two-option UI * Option 1: OAuth refresh token (future, not yet available) * Option 2: App password (works today, recommended) * Shows "Active" badge when provisioned * Displays credential type and provisioned timestamp Routes: - POST /api/v1/background-sync/credentials - Store app password - GET /api/v1/background-sync/status - Get provisioning status - DELETE /api/v1/background-sync/credentials - Revoke credentials - GET /api/v1/background-sync/credentials/{userId} - Admin only **Testing:** - test_astrolabe_settings_buttons.py: Integration test for UI buttons **Workflow:** 1. User generates app password in Nextcloud Security settings 2. User navigates to Astrolabe personal settings 3. User enters app password in "Option 2: App Password" form 4. Backend validates password via OCS API call 5. Password stored encrypted in oc_preferences 6. Page reloads showing "Active" badge with credential details 7. MCP server can now use stored password for background operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../test_astrolabe_settings_buttons.py | 91 ++++++ third_party/astrolabe/appinfo/routes.php | 22 ++ .../astrolabe/lib/AppInfo/Application.php | 17 + .../lib/Controller/ApiController.php | 6 + .../lib/Controller/CredentialsController.php | 258 ++++++++++++++++ .../AstrolabeAdminSettingsListener.php | 101 ++++++ .../astrolabe/lib/Service/McpTokenStorage.php | 172 +++++++++++ .../lib/Settings/AstrolabeAdminSettings.php | 64 ++++ .../astrolabe/lib/Settings/Personal.php | 88 +++++- third_party/astrolabe/src/adminSettings.js | 1 + third_party/astrolabe/src/personalSettings.js | 124 ++++++++ third_party/astrolabe/src/styles/settings.css | 290 ++++++++++++++++++ third_party/astrolabe/templates/index.php | 1 + .../astrolabe/templates/settings/admin.php | 95 +----- .../astrolabe/templates/settings/error.php | 2 - .../templates/settings/oauth-required.php | 44 +-- .../astrolabe/templates/settings/personal.php | 240 ++++++++------- third_party/astrolabe/vite.config.js | 3 +- 18 files changed, 1387 insertions(+), 232 deletions(-) create mode 100644 tests/integration/test_astrolabe_settings_buttons.py create mode 100644 third_party/astrolabe/lib/Controller/CredentialsController.php create mode 100644 third_party/astrolabe/lib/Listener/AstrolabeAdminSettingsListener.php create mode 100644 third_party/astrolabe/lib/Settings/AstrolabeAdminSettings.php create mode 100644 third_party/astrolabe/src/personalSettings.js create mode 100644 third_party/astrolabe/src/styles/settings.css 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/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', }) From 4a5766b84e477ae62492e136164b43e19b989cc6 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 22 Dec 2025 19:43:24 +0100 Subject: [PATCH 4/4] feat(config): enable DCR for multi-user BasicAuth with offline access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows multi-user BasicAuth mode to use Dynamic Client Registration (DCR) for OAuth credentials when ENABLE_OFFLINE_ACCESS is enabled, making it consistent with OAuth modes and reducing configuration burden. **Changes:** Configuration Validation: - Relaxed OAuth credential requirements for multi-user BasicAuth - OAuth credentials now optional when offline access enabled - Will use DCR as fallback if NEXTCLOUD_OIDC_CLIENT_ID/SECRET not set - Updated validation to log info instead of error when DCR will be used Startup Logic (app.py): - Added DCR workflow for multi-user BasicAuth before uvicorn starts - Creates oauth_context for management APIs when offline access enabled - Allows Astrolabe to authenticate management API calls with OAuth - DCR runs synchronously at same lifecycle point as OAuth modes - Added traceback import for better error logging - Fixed type assertions for nextcloud_host - Fixed undefined variable references in vector sync logging Management API: - Improved auth mode detection using proper detect_auth_mode() - Added auth_mode field to /status endpoint: * "basic" - Single-user BasicAuth * "multi_user_basic" - Multi-user BasicAuth * "oauth" - OAuth modes * "smithery" - Smithery stateless - Added supports_app_passwords indicator for multi-user BasicAuth Docker Compose: - Updated mcp-multi-user-basic service configuration: * Enabled vector sync (VECTOR_SYNC_ENABLED=true) * Added ENABLE_OFFLINE_ACCESS=true for app password support * Added NEXTCLOUD_MCP_SERVER_URL for Astrolabe integration * Documented optional static OAuth credentials Testing: - Updated test_config_validators.py to expect DCR fallback - Enhanced configure_astrolabe_for_mcp_server fixture with verification - Added debug logging to test_users_setup fixture **Workflow:** 1. User configures ENABLE_OFFLINE_ACCESS=true 2. Server checks for static NEXTCLOUD_OIDC_CLIENT_ID/SECRET 3. If not found, performs DCR before uvicorn starts 4. DCR registers client with Nextcloud OIDC provider 5. OAuth credentials used for Astrolabe management API auth 6. Background sync can retrieve user app passwords via Astrolabe 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- docker-compose.yml | 15 +- nextcloud_mcp_server/api/management.py | 25 +- nextcloud_mcp_server/app.py | 420 +++++++++++++++------- nextcloud_mcp_server/config_validators.py | 15 +- tests/conftest.py | 56 ++- tests/unit/test_config_validators.py | 5 +- 6 files changed, 379 insertions(+), 157 deletions(-) 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/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_validators.py b/nextcloud_mcp_server/config_validators.py index 0fbc872..db6236c 100644 --- a/nextcloud_mcp_server/config_validators.py +++ b/nextcloud_mcp_server/config_validators.py @@ -105,8 +105,7 @@ 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", ], @@ -395,14 +394,14 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]: ) if mode == AuthMode.MULTI_USER_BASIC: - # Validate that if background operations 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_BACKGROUND_OPERATIONS (or deprecated 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)." ) # Note: Vector sync no longer requires explicit ENABLE_OFFLINE_ACCESS setting 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/unit/test_config_validators.py b/tests/unit/test_config_validators.py index 07b50ee..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."""