Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 546f0c0674 | |||
| e625eab689 | |||
| a26a470af6 | |||
| 71ace47197 | |||
| 30d3d9f0cf | |||
| ef9e1b3ff8 | |||
| dd23191987 | |||
| 55312b1032 | |||
| 48a4182ef9 | |||
| 13dd709fc2 | |||
| dd66d4bbbc | |||
| 663e66af81 | |||
| 9c17bbfe9c | |||
| 052db2cf56 | |||
| 056414752e | |||
| b841407f07 | |||
| 555c26526e | |||
| 5b9e91bdee | |||
| 5d49b5903a | |||
| 9a6a253858 | |||
| 0a23e484e9 | |||
| 779d474aaa | |||
| 894bf5f916 | |||
| 804480836e | |||
| 5e2ef5f35b | |||
| a51376fd5a | |||
| 10a0969138 | |||
| 5e76ddc60d | |||
| 9ea1902e2b | |||
| dd42849d70 | |||
| 4248b67b2e | |||
| 755e398a1f | |||
| 036c6352fb | |||
| d7c99fcc69 | |||
| 47095fabcd | |||
| 85b7b935b3 | |||
| 6e2be579e0 | |||
| 8ba3ae73ab | |||
| dbf3d5ec10 | |||
| 5b9e76ddb4 | |||
| 541f7a6abd | |||
| 28cfee4bab | |||
| 358d962822 | |||
| ea96a58678 | |||
| 9b5c6779e9 | |||
| 04140d671e | |||
| ff8828e972 | |||
| 43c7421d28 | |||
| e49dc2bfc4 | |||
| 4a5766b84e | |||
| 65c3f099fa | |||
| b293258210 | |||
| 8f83034c79 | |||
| d195fc43d2 | |||
| 1a5bb10cd0 | |||
| 34273ec01e | |||
| fd7f33943d | |||
| ecaa1f8f01 | |||
| d29922039b | |||
| 12541e57a6 | |||
| b99418451c |
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code Review
|
- name: Run Claude Code Review
|
||||||
id: claude-review
|
id: claude-review
|
||||||
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
prompt: |
|
prompt: |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- v*
|
- v*
|
||||||
|
- nextcloud-mcp-server-*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
|||||||
@@ -5,6 +5,91 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||||
|
|
||||||
|
## v0.60.3 (2025-12-31)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deck**: Always preserve fields in update_card for partial updates
|
||||||
|
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||||
|
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||||
|
|
||||||
|
## v0.60.2 (2025-12-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
||||||
|
|
||||||
|
## v0.60.1 (2025-12-26)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **mcp**: Move all imports to the top of modules
|
||||||
|
|
||||||
|
## v0.60.0 (2025-12-26)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Remove URL rewriting in favor of proper nextcloud config
|
||||||
|
- **helm**: migrate to new environment variable naming convention
|
||||||
|
- Migrate to vue 3
|
||||||
|
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
|
||||||
|
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
|
||||||
|
- **auth**: Skip issuer validation for management API tokens
|
||||||
|
- Use settings.enable_offline_access for env var consolidation
|
||||||
|
- Add required config.py attributes
|
||||||
|
- **docker**: remove overwritehost to fix container-to-container DCR
|
||||||
|
- **deps**: update dependency @nextcloud/vue to v9
|
||||||
|
- **deps**: update dependency vue to v3
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **auth**: Decouple BasicAuth and OAuth authentication strategies
|
||||||
|
|
||||||
|
## v0.59.1 (2025-12-22)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: set OIDC client env vars when using existingSecret
|
||||||
|
- **helm**: trigger chart release workflow on helm chart tags
|
||||||
|
|
||||||
|
## v0.59.0 (2025-12-22)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **helm**: add support for multi-user BasicAuth mode
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: address PR #447 reviewer feedback
|
||||||
|
- **helm**: include MCP server version bumps in changelog pattern
|
||||||
|
|
||||||
|
## v0.58.0 (2025-12-22)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **config**: enable DCR for multi-user BasicAuth with offline access
|
||||||
|
- **astrolabe**: implement app password provisioning for multi-user background sync
|
||||||
|
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
|
||||||
|
|
||||||
|
## v0.57.0 (2025-12-20)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **auth**: add multi-user BasicAuth pass-through mode
|
||||||
|
- **astrolabe**: add dynamic MCP server configuration for testing
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **config**: address reviewer feedback
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **config**: centralize configuration validation and simplify startup
|
||||||
|
|
||||||
## v0.56.2 (2025-12-20)
|
## v0.56.2 (2025-12-20)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
|||||||
|
|
||||||
### Authentication Modes
|
### Authentication Modes
|
||||||
|
|
||||||
The server supports two authentication modes:
|
The server supports three authentication modes:
|
||||||
|
|
||||||
**Single-User Mode (BasicAuth):**
|
**Single-User Mode (BasicAuth):**
|
||||||
- One set of credentials shared by all MCP clients
|
- One set of credentials shared by all MCP clients
|
||||||
@@ -113,6 +113,12 @@ The server supports two authentication modes:
|
|||||||
- More secure: tokens expire, credentials never shared with server
|
- More secure: tokens expire, credentials never shared with server
|
||||||
- Best for: Teams, multi-user deployments, production environments with multiple users
|
- Best for: Teams, multi-user deployments, production environments with multiple users
|
||||||
|
|
||||||
|
**Hybrid Mode (Multi-User BasicAuth + OAuth):**
|
||||||
|
- MCP clients use BasicAuth (simple, stateless)
|
||||||
|
- Admin operations use OAuth (webhooks, background sync)
|
||||||
|
- Best for: Nextcloud deployments with admin-managed webhooks and semantic search
|
||||||
|
- Requires: `ENABLE_MULTI_USER_BASIC_AUTH=true` + `ENABLE_OFFLINE_ACCESS=true`
|
||||||
|
|
||||||
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
|
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
|
||||||
|
|
||||||
## Semantic Search
|
## Semantic Search
|
||||||
@@ -127,7 +133,7 @@ This enables natural language queries and helps discover related content across
|
|||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **Semantic Search is experimental and opt-in:**
|
> **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)
|
> - Currently supports Notes app only (multi-app support planned)
|
||||||
> - Requires additional infrastructure: vector database + embedding service
|
> - Requires additional infrastructure: vector database + embedding service
|
||||||
> - Answer generation (`nc_semantic_search_answer`) requires MCP client sampling support
|
> - Answer generation (`nc_semantic_search_answer`) requires MCP client sampling support
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ set -euox pipefail
|
|||||||
|
|
||||||
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
||||||
|
|
||||||
# Set overwrite settings for URL generation (needed for OIDC discovery to return correct URLs)
|
# Set overwrite.cli.url to the external URL for OIDC discovery
|
||||||
# These ensure that URLs generated by Nextcloud include the correct host:port
|
# This ensures OAuth flows redirect to the correct external URL
|
||||||
php /var/www/html/occ config:system:set overwritehost --value="localhost:8080"
|
# Important: The Astrolabe OAuth controller makes internal HTTP requests to /.well-known/openid-configuration
|
||||||
php /var/www/html/occ config:system:set overwriteprotocol --value="http"
|
# which needs to return URLs reachable by external browsers (localhost:8080, not localhost:80)
|
||||||
php /var/www/html/occ config:system:set overwrite.cli.url --value="http://localhost:8080"
|
php /var/www/html/occ config:system:set overwrite.cli.url --value="http://localhost:8080"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.54.0"
|
version = "0.56.2"
|
||||||
tag_format = "nextcloud-mcp-server-$version"
|
tag_format = "nextcloud-mcp-server-$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
@@ -18,7 +18,8 @@ ignored_tag_formats = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Filter commits by scope
|
# Filter commits by scope
|
||||||
|
# Includes helm-scoped commits AND MCP server version bumps (which update appVersion)
|
||||||
[tool.commitizen.customize]
|
[tool.commitizen.customize]
|
||||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:"
|
changelog_pattern = "^((feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:|bump: version.*→.*)"
|
||||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:\\s.+"
|
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:\\s.+"
|
||||||
message_template = "{{change_type}}(helm): {{message}}"
|
message_template = "{{change_type}}(helm): {{message}}"
|
||||||
|
|||||||
@@ -14,6 +14,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Configurable resource limits
|
- Configurable resource limits
|
||||||
- Grafana dashboard annotations
|
- Grafana dashboard annotations
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.56.2 (2025-12-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.56.1 (2025-12-26)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **mcp**: Move all imports to the top of modules
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.56.0 (2025-12-26)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Remove URL rewriting in favor of proper nextcloud config
|
||||||
|
- **helm**: migrate to new environment variable naming convention
|
||||||
|
- Migrate to vue 3
|
||||||
|
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
|
||||||
|
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
|
||||||
|
- **auth**: Skip issuer validation for management API tokens
|
||||||
|
- Use settings.enable_offline_access for env var consolidation
|
||||||
|
- Add required config.py attributes
|
||||||
|
- **docker**: remove overwritehost to fix container-to-container DCR
|
||||||
|
- **deps**: update dependency @nextcloud/vue to v9
|
||||||
|
- **deps**: update dependency vue to v3
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **auth**: Decouple BasicAuth and OAuth authentication strategies
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.55.2 (2025-12-22)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: set OIDC client env vars when using existingSecret
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.55.1 (2025-12-22)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: trigger chart release workflow on helm chart tags
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.55.0 (2025-12-22)
|
||||||
|
|
||||||
|
### BREAKING CHANGE
|
||||||
|
|
||||||
|
- MCP server now bumps for ANY conventional commit except
|
||||||
|
those explicitly scoped to helm or astrolabe.
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **helm**: add support for multi-user BasicAuth mode
|
||||||
|
- **config**: enable DCR for multi-user BasicAuth with offline access
|
||||||
|
- **astrolabe**: implement app password provisioning for multi-user background sync
|
||||||
|
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
|
||||||
|
- **auth**: add multi-user BasicAuth pass-through mode
|
||||||
|
- **astrolabe**: add dynamic MCP server configuration for testing
|
||||||
|
- **ci**: add --increment flag to bump scripts for manual version control
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: address PR #447 reviewer feedback
|
||||||
|
- **helm**: include MCP server version bumps in changelog pattern
|
||||||
|
- **config**: address reviewer feedback
|
||||||
|
- **astrolabe**: screenshots in info.xml
|
||||||
|
- **astrolabe**: screenshots in info.xml
|
||||||
|
- **astrolabe**: Update screenshots
|
||||||
|
- **ci**: skip existing Helm chart releases to prevent duplicate release errors
|
||||||
|
- **astrolabe**: add contents:write permission to appstore workflow
|
||||||
|
- **astrolabe**: update commitizen pattern to properly update info.xml version
|
||||||
|
- **astrolabe**: prevent workflow failure when only helm/astrolabe commits exist
|
||||||
|
- **astrolabe**: info.xml
|
||||||
|
- **ci**: push all tags explicitly in bump workflow
|
||||||
|
- **ci**: make MCP server default bump target for all non-scoped commits
|
||||||
|
- **ci**: restrict docker build to MCP server tags only
|
||||||
|
- **ci**: correct appstore-push-action version to v1.0.4
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **config**: centralize configuration validation and simplify startup
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.54.0 (2025-12-19)
|
## nextcloud-mcp-server-0.54.0 (2025-12-19)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
version: 1.16.2
|
version: 1.16.3
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.36.0
|
version: 1.36.0
|
||||||
digest: sha256:fab8008217b27ff4e3e139c2f481eedaa23f9f64a3a086d0e9deea2195b69b63
|
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
|
||||||
generated: "2025-12-14T11:07:07.024787592Z"
|
generated: "2025-12-22T11:09:39.166328543Z"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.54.0
|
version: 0.56.2
|
||||||
appVersion: "0.56.2"
|
appVersion: "0.60.3"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -27,7 +27,7 @@ annotations:
|
|||||||
grafana_dashboard_folder: "Nextcloud MCP"
|
grafana_dashboard_folder: "Nextcloud MCP"
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
version: "1.16.2"
|
version: "1.16.3"
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ ingress:
|
|||||||
|-----------|-------------|---------|
|
|-----------|-------------|---------|
|
||||||
| `nextcloud.host` | URL of your Nextcloud instance (required) | `""` |
|
| `nextcloud.host` | URL of your Nextcloud instance (required) | `""` |
|
||||||
| `nextcloud.mcpServerUrl` | MCP server URL for OAuth callbacks (OAuth only, optional) | Smart default* |
|
| `nextcloud.mcpServerUrl` | MCP server URL for OAuth callbacks (OAuth only, optional) | Smart default* |
|
||||||
| `nextcloud.publicIssuerUrl` | Public issuer URL for OAuth (OAuth only, optional) | Smart default** |
|
| `nextcloud.publicIssuerUrl` | Public URL for browser-accessible OAuth authorization endpoint (OAuth only, optional) | Smart default** |
|
||||||
|
|
||||||
**Smart Defaults:**
|
**Smart Defaults:**
|
||||||
- `*mcpServerUrl`: If not set, automatically uses ingress host (if enabled) or `http://localhost:8000` (for port-forward setups)
|
- `*mcpServerUrl`: If not set, automatically uses ingress host (if enabled) or `http://localhost:8000` (for port-forward setups)
|
||||||
- `**publicIssuerUrl`: If not set, automatically defaults to `nextcloud.host` (which works when both clients and MCP server access Nextcloud at the same URL)
|
- `**publicIssuerUrl`: If not set, defaults to `nextcloud.host`. **Only used for authorization endpoints** that browsers must access. All server-to-server endpoints (token, JWKS, introspection, userinfo) use URLs from OIDC discovery without rewriting
|
||||||
|
|
||||||
#### Authentication
|
#### Authentication
|
||||||
|
|
||||||
@@ -208,16 +208,16 @@ The application exposes HTTP health check endpoints:
|
|||||||
|
|
||||||
#### Vector Search & Semantic Capabilities (Optional)
|
#### Vector Search & Semantic Capabilities (Optional)
|
||||||
|
|
||||||
Enable semantic search capabilities by deploying a vector database (Qdrant) and embedding service (Ollama or OpenAI).
|
Enable semantic search capabilities with BM25 hybrid search by deploying a vector database (Qdrant) and embedding service (Ollama or OpenAI).
|
||||||
|
|
||||||
**Vector Sync Configuration:**
|
**Semantic Search Configuration:**
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|-----------|-------------|---------|
|
|-----------|-------------|---------|
|
||||||
| `vectorSync.enabled` | Enable background vector synchronization | `false` |
|
| `semanticSearch.enabled` | Enable semantic search and background vector synchronization | `false` |
|
||||||
| `vectorSync.scanInterval` | Scan interval in seconds | `3600` |
|
| `semanticSearch.scanInterval` | Scan interval in seconds | `3600` |
|
||||||
| `vectorSync.processorWorkers` | Number of concurrent processor workers | `3` |
|
| `semanticSearch.processorWorkers` | Number of concurrent processor workers | `3` |
|
||||||
| `vectorSync.queueMaxSize` | Maximum queue size for pending documents | `10000` |
|
| `semanticSearch.queueMaxSize` | Maximum queue size for pending documents | `10000` |
|
||||||
|
|
||||||
**Document Chunking Configuration:**
|
**Document Chunking Configuration:**
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@ nextcloud:
|
|||||||
host: https://cloud.example.com
|
host: https://cloud.example.com
|
||||||
# mcpServerUrl and publicIssuerUrl are optional!
|
# mcpServerUrl and publicIssuerUrl are optional!
|
||||||
# If not set, mcpServerUrl defaults to ingress host or localhost
|
# If not set, mcpServerUrl defaults to ingress host or localhost
|
||||||
# publicIssuerUrl defaults to nextcloud.host
|
# publicIssuerUrl defaults to nextcloud.host (only used for browser-accessible auth endpoint)
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
mode: oauth
|
mode: oauth
|
||||||
@@ -459,7 +459,7 @@ This example shows OAuth without pre-registered credentials (using DCR) and opti
|
|||||||
nextcloud:
|
nextcloud:
|
||||||
host: https://cloud.example.com
|
host: https://cloud.example.com
|
||||||
# mcpServerUrl will automatically use ingress host (https://mcp.example.com)
|
# mcpServerUrl will automatically use ingress host (https://mcp.example.com)
|
||||||
# publicIssuerUrl will automatically default to nextcloud.host
|
# publicIssuerUrl will automatically default to nextcloud.host (only used for browser-accessible auth endpoint)
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
mode: oauth
|
mode: oauth
|
||||||
@@ -537,8 +537,8 @@ auth:
|
|||||||
username: admin
|
username: admin
|
||||||
password: secure-password
|
password: secure-password
|
||||||
|
|
||||||
# Enable vector sync
|
# Enable semantic search
|
||||||
vectorSync:
|
semanticSearch:
|
||||||
enabled: true
|
enabled: true
|
||||||
scanInterval: 1800 # Scan every 30 minutes
|
scanInterval: 1800 # Scan every 30 minutes
|
||||||
processorWorkers: 5
|
processorWorkers: 5
|
||||||
@@ -576,7 +576,7 @@ ollama:
|
|||||||
Or use an external Ollama instance:
|
Or use an external Ollama instance:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
vectorSync:
|
semanticSearch:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
@@ -592,7 +592,7 @@ ollama:
|
|||||||
Or use OpenAI for embeddings:
|
Or use OpenAI for embeddings:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
vectorSync:
|
semanticSearch:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
@@ -689,7 +689,9 @@ Readiness (returns 200 if ready, 503 if not ready):
|
|||||||
|
|
||||||
1. **Connection refused to Nextcloud**
|
1. **Connection refused to Nextcloud**
|
||||||
- Verify `nextcloud.host` is accessible from the Kubernetes cluster
|
- Verify `nextcloud.host` is accessible from the Kubernetes cluster
|
||||||
|
- For OAuth mode: Ensure MCP server can reach OIDC discovery endpoints (token, JWKS, introspection, userinfo URLs)
|
||||||
- Check network policies and firewall rules
|
- Check network policies and firewall rules
|
||||||
|
- Note: Do not use internal Docker hostnames (like `http://app:80`) for `nextcloud.host` - use externally resolvable URLs
|
||||||
|
|
||||||
2. **Authentication failures**
|
2. **Authentication failures**
|
||||||
- For basic auth: verify username/password are correct
|
- For basic auth: verify username/password are correct
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- if .Values.vectorSync.enabled }}
|
{{- if .Values.semanticSearch.enabled }}
|
||||||
|
|
||||||
5. Vector Search & Semantic Capabilities:
|
5. Semantic Search & Vector Capabilities:
|
||||||
- Vector Sync: Enabled
|
- Semantic Search: Enabled
|
||||||
- Scan Interval: {{ .Values.vectorSync.scanInterval }}s
|
- Scan Interval: {{ .Values.semanticSearch.scanInterval }}s
|
||||||
- Processor Workers: {{ .Values.vectorSync.processorWorkers }}
|
- Processor Workers: {{ .Values.semanticSearch.processorWorkers }}
|
||||||
{{- if .Values.qdrant.enabled }}
|
{{- if .Values.qdrant.enabled }}
|
||||||
- Qdrant: Deployed as subchart ({{ .Release.Name }}-qdrant:6333)
|
- Qdrant: Deployed as subchart ({{ .Release.Name }}-qdrant:6333)
|
||||||
{{- else }}
|
{{- else }}
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ Create the name of the secret to use for basic auth
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the secret to use for multi-user basic auth
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.multiUserBasicSecretName" -}}
|
||||||
|
{{- if .Values.auth.multiUserBasic.existingSecret }}
|
||||||
|
{{- .Values.auth.multiUserBasic.existingSecret }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "nextcloud-mcp-server.fullname" . }}-multi-user-basic
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the PVC to use for multi-user basic token storage
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.multiUserBasicPvcName" -}}
|
||||||
|
{{- if .Values.auth.multiUserBasic.persistence.existingClaim }}
|
||||||
|
{{- .Values.auth.multiUserBasic.persistence.existingClaim }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "nextcloud-mcp-server.fullname" . }}-token-storage
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Create the name of the secret to use for OAuth
|
Create the name of the secret to use for OAuth
|
||||||
*/}}
|
*/}}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ spec:
|
|||||||
- name: NEXTCLOUD_HOST
|
- name: NEXTCLOUD_HOST
|
||||||
value: {{ .Values.nextcloud.host | quote }}
|
value: {{ .Values.nextcloud.host | quote }}
|
||||||
{{- if eq .Values.auth.mode "basic" }}
|
{{- if eq .Values.auth.mode "basic" }}
|
||||||
# Basic auth mode
|
# Basic auth mode (single-user)
|
||||||
- name: NEXTCLOUD_USERNAME
|
- name: NEXTCLOUD_USERNAME
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -79,6 +79,41 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
|
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
|
||||||
key: {{ .Values.auth.basic.passwordKey }}
|
key: {{ .Values.auth.basic.passwordKey }}
|
||||||
|
{{- else if eq .Values.auth.mode "multi-user-basic" }}
|
||||||
|
# Multi-user BasicAuth mode (pass-through)
|
||||||
|
- name: ENABLE_MULTI_USER_BASIC_AUTH
|
||||||
|
value: "true"
|
||||||
|
- name: NEXTCLOUD_MCP_SERVER_URL
|
||||||
|
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
|
||||||
|
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
|
||||||
|
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
||||||
|
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
|
||||||
|
# Background operations with app passwords (replaces deprecated ENABLE_OFFLINE_ACCESS)
|
||||||
|
- name: ENABLE_BACKGROUND_OPERATIONS
|
||||||
|
value: "true"
|
||||||
|
- name: TOKEN_STORAGE_DB
|
||||||
|
value: {{ .Values.auth.multiUserBasic.tokenStorageDb | quote }}
|
||||||
|
- name: TOKEN_ENCRYPTION_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
|
||||||
|
key: {{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}
|
||||||
|
- name: NEXTCLOUD_OIDC_SCOPES
|
||||||
|
value: {{ .Values.auth.multiUserBasic.scopes | quote }}
|
||||||
|
{{- if or .Values.auth.multiUserBasic.clientId .Values.auth.multiUserBasic.existingSecret }}
|
||||||
|
# Static OAuth credentials (optional - uses DCR if not provided)
|
||||||
|
- name: NEXTCLOUD_OIDC_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
|
||||||
|
key: {{ .Values.auth.multiUserBasic.clientIdKey }}
|
||||||
|
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
|
||||||
|
key: {{ .Values.auth.multiUserBasic.clientSecretKey }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
{{- else if eq .Values.auth.mode "oauth" }}
|
{{- else if eq .Values.auth.mode "oauth" }}
|
||||||
# OAuth mode
|
# OAuth mode
|
||||||
- name: NEXTCLOUD_MCP_SERVER_URL
|
- name: NEXTCLOUD_MCP_SERVER_URL
|
||||||
@@ -87,7 +122,7 @@ spec:
|
|||||||
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
||||||
- name: NEXTCLOUD_OIDC_SCOPES
|
- name: NEXTCLOUD_OIDC_SCOPES
|
||||||
value: {{ .Values.auth.oauth.scopes | quote }}
|
value: {{ .Values.auth.oauth.scopes | quote }}
|
||||||
{{- if .Values.auth.oauth.clientId }}
|
{{- if or .Values.auth.oauth.clientId .Values.auth.oauth.existingSecret }}
|
||||||
- name: NEXTCLOUD_OIDC_CLIENT_ID
|
- name: NEXTCLOUD_OIDC_CLIENT_ID
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -147,16 +182,16 @@ spec:
|
|||||||
value: {{ .Values.documentProcessing.custom.types | quote }}
|
value: {{ .Values.documentProcessing.custom.types | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
# Vector Sync
|
# Semantic Search (replaces deprecated VECTOR_SYNC_ENABLED)
|
||||||
- name: VECTOR_SYNC_ENABLED
|
- name: ENABLE_SEMANTIC_SEARCH
|
||||||
value: {{ .Values.vectorSync.enabled | quote }}
|
value: {{ .Values.semanticSearch.enabled | quote }}
|
||||||
{{- if .Values.vectorSync.enabled }}
|
{{- if .Values.semanticSearch.enabled }}
|
||||||
- name: VECTOR_SYNC_SCAN_INTERVAL
|
- name: VECTOR_SYNC_SCAN_INTERVAL
|
||||||
value: {{ .Values.vectorSync.scanInterval | quote }}
|
value: {{ .Values.semanticSearch.scanInterval | quote }}
|
||||||
- name: VECTOR_SYNC_PROCESSOR_WORKERS
|
- name: VECTOR_SYNC_PROCESSOR_WORKERS
|
||||||
value: {{ .Values.vectorSync.processorWorkers | quote }}
|
value: {{ .Values.semanticSearch.processorWorkers | quote }}
|
||||||
- name: VECTOR_SYNC_QUEUE_MAX_SIZE
|
- name: VECTOR_SYNC_QUEUE_MAX_SIZE
|
||||||
value: {{ .Values.vectorSync.queueMaxSize | quote }}
|
value: {{ .Values.semanticSearch.queueMaxSize | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
# Document Chunking (always set, used by vector sync processor)
|
# Document Chunking (always set, used by vector sync processor)
|
||||||
- name: DOCUMENT_CHUNK_SIZE
|
- name: DOCUMENT_CHUNK_SIZE
|
||||||
@@ -251,6 +286,10 @@ spec:
|
|||||||
- name: oauth-storage
|
- name: oauth-storage
|
||||||
mountPath: /app/.oauth
|
mountPath: /app/.oauth
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
||||||
|
- name: token-storage
|
||||||
|
mountPath: /app/data
|
||||||
|
{{- end }}
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||||
- name: qdrant-data
|
- name: qdrant-data
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
@@ -266,6 +305,11 @@ spec:
|
|||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
||||||
|
- name: token-storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "nextcloud-mcp-server.multiUserBasicPvcName" . }}
|
||||||
|
{{- end }}
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||||
- name: qdrant-data
|
- name: qdrant-data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ spec:
|
|||||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
---
|
---
|
||||||
|
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.auth.multiUserBasic.persistence.existingClaim) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-token-storage
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- {{ .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||||
|
{{- if .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||||
|
storageClassName: {{ .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||||
|
{{- end }}
|
||||||
|
---
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ data:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
---
|
---
|
||||||
|
{{- if eq .Values.auth.mode "multi-user-basic" }}
|
||||||
|
{{- if and .Values.auth.multiUserBasic.enableOfflineAccess (not .Values.auth.multiUserBasic.existingSecret) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-multi-user-basic
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
{{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}: {{ .Values.auth.multiUserBasic.tokenEncryptionKey | b64enc | quote }}
|
||||||
|
{{- if .Values.auth.multiUserBasic.clientId }}
|
||||||
|
{{ .Values.auth.multiUserBasic.clientIdKey }}: {{ .Values.auth.multiUserBasic.clientId | b64enc | quote }}
|
||||||
|
{{ .Values.auth.multiUserBasic.clientSecretKey }}: {{ .Values.auth.multiUserBasic.clientSecret | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
---
|
||||||
{{- if eq .Values.auth.mode "oauth" }}
|
{{- if eq .Values.auth.mode "oauth" }}
|
||||||
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
|
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|||||||
@@ -26,21 +26,29 @@ nextcloud:
|
|||||||
# Example: https://mcp.example.com
|
# Example: https://mcp.example.com
|
||||||
mcpServerUrl: ""
|
mcpServerUrl: ""
|
||||||
|
|
||||||
# Public issuer URL for OAuth (OAuth mode only)
|
# Public issuer URL for browser-accessible OAuth authorization endpoints (OAuth mode only)
|
||||||
# If not specified, defaults to nextcloud.host
|
# ONLY used to make authorization endpoints accessible to users' browsers
|
||||||
# Only set this if your Nextcloud is accessible at a different URL for OAuth
|
# All server-to-server communication (token endpoint, JWKS, introspection, userinfo)
|
||||||
|
# uses URLs from OIDC discovery without any rewriting
|
||||||
|
#
|
||||||
|
# Use case: When MCP server accesses Nextcloud at one URL but browsers need a different
|
||||||
|
# public URL for OAuth login (e.g., server uses internal DNS, browsers use public domain)
|
||||||
|
#
|
||||||
|
# If not specified, defaults to nextcloud.host (works when MCP server and browsers
|
||||||
|
# both access Nextcloud at the same URL)
|
||||||
# Example: https://cloud.example.com
|
# Example: https://cloud.example.com
|
||||||
publicIssuerUrl: ""
|
publicIssuerUrl: ""
|
||||||
|
|
||||||
# Authentication configuration
|
# Authentication configuration
|
||||||
# Choose either basic auth OR oauth (not both)
|
# Choose one mode: "basic", "multi-user-basic", or "oauth"
|
||||||
auth:
|
auth:
|
||||||
# Authentication mode: "basic" or "oauth"
|
# Authentication mode: "basic", "multi-user-basic", or "oauth"
|
||||||
# basic: Uses username/password (recommended for most users)
|
# basic: Single-user with username/password (recommended for personal use)
|
||||||
|
# multi-user-basic: Multi-user with BasicAuth pass-through (credentials in request headers)
|
||||||
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
|
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
|
||||||
mode: basic
|
mode: basic
|
||||||
|
|
||||||
# Basic authentication settings
|
# Basic authentication settings (single-user mode)
|
||||||
basic:
|
basic:
|
||||||
# Nextcloud username (ignored if existingSecret is set)
|
# Nextcloud username (ignored if existingSecret is set)
|
||||||
username: ""
|
username: ""
|
||||||
@@ -58,6 +66,47 @@ auth:
|
|||||||
usernameKey: "username"
|
usernameKey: "username"
|
||||||
passwordKey: "password"
|
passwordKey: "password"
|
||||||
|
|
||||||
|
# Multi-user BasicAuth settings (pass-through mode)
|
||||||
|
# Users provide credentials in request headers (Authorization: Basic ...)
|
||||||
|
# Server optionally stores app passwords for background operations
|
||||||
|
multiUserBasic:
|
||||||
|
# Enable offline access (background operations using app passwords via Astrolabe)
|
||||||
|
# When enabled, requires token encryption key. OAuth client credentials are optional (uses DCR if not provided)
|
||||||
|
enableOfflineAccess: false
|
||||||
|
# Token encryption key (required if enableOfflineAccess: true, ignored if existingSecret is set)
|
||||||
|
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
tokenEncryptionKey: ""
|
||||||
|
# Token storage database path
|
||||||
|
tokenStorageDb: "/app/data/tokens.db"
|
||||||
|
# OAuth client credentials (optional - uses Dynamic Client Registration if not provided)
|
||||||
|
# Only needed if enableOfflineAccess: true
|
||||||
|
clientId: ""
|
||||||
|
clientSecret: ""
|
||||||
|
# OAuth scopes to request (space-separated)
|
||||||
|
scopes: "openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write"
|
||||||
|
# Use existing secret for multi-user basic auth credentials
|
||||||
|
# If set, tokenEncryptionKey, clientId, and clientSecret above are ignored
|
||||||
|
# Secret should contain keys specified in the *Key fields below
|
||||||
|
# Example:
|
||||||
|
# kubectl create secret generic my-multiuser-creds \
|
||||||
|
# --from-literal=token_encryption_key=ESF1BvEQ... \
|
||||||
|
# --from-literal=client_id=my-client-id \
|
||||||
|
# --from-literal=client_secret=my-client-secret
|
||||||
|
existingSecret: ""
|
||||||
|
# Keys in the existing secret
|
||||||
|
tokenEncryptionKeyKey: "token_encryption_key"
|
||||||
|
clientIdKey: "client_id"
|
||||||
|
clientSecretKey: "client_secret"
|
||||||
|
# Persistent storage for token database
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
# Storage class (leave empty for default)
|
||||||
|
storageClass: ""
|
||||||
|
accessMode: ReadWriteOnce
|
||||||
|
size: 100Mi
|
||||||
|
# Use existing PVC
|
||||||
|
existingClaim: ""
|
||||||
|
|
||||||
# OAuth2/OIDC settings (experimental)
|
# OAuth2/OIDC settings (experimental)
|
||||||
oauth:
|
oauth:
|
||||||
# OAuth token type: "jwt" or "opaque"
|
# OAuth token type: "jwt" or "opaque"
|
||||||
@@ -316,10 +365,11 @@ extraEnvFrom: []
|
|||||||
# - secretRef:
|
# - secretRef:
|
||||||
# name: my-secret
|
# name: my-secret
|
||||||
|
|
||||||
# Vector Sync Configuration
|
# Semantic Search Configuration
|
||||||
# Background synchronization of Nextcloud content into vector database for semantic search
|
# Enable semantic search with BM25 hybrid search and background synchronization
|
||||||
vectorSync:
|
# of Nextcloud content into vector database
|
||||||
# Enable background vector synchronization
|
semanticSearch:
|
||||||
|
# Enable semantic search and background vector synchronization
|
||||||
enabled: false
|
enabled: false
|
||||||
# Scan interval in seconds (how often to check for changes)
|
# Scan interval in seconds (how often to check for changes)
|
||||||
scanInterval: 3600
|
scanInterval: 3600
|
||||||
@@ -330,7 +380,7 @@ vectorSync:
|
|||||||
|
|
||||||
# Document Chunking Configuration
|
# Document Chunking Configuration
|
||||||
# Controls how documents are split into chunks before embedding
|
# Controls how documents are split into chunks before embedding
|
||||||
# Only relevant when vectorSync.enabled is true
|
# Only relevant when semanticSearch.enabled is true
|
||||||
documentChunking:
|
documentChunking:
|
||||||
# Number of words per chunk (default: 512)
|
# Number of words per chunk (default: 512)
|
||||||
# Smaller chunks (256-384): Better for precise searches, more chunks to store
|
# Smaller chunks (256-384): Better for precise searches, more chunks to store
|
||||||
|
|||||||
+21
-7
@@ -8,6 +8,8 @@ services:
|
|||||||
command: --transaction-isolation=READ-COMMITTED
|
command: --transaction-isolation=READ-COMMITTED
|
||||||
volumes:
|
volumes:
|
||||||
- db:/var/lib/mysql
|
- db:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:3306:3306
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_ROOT_PASSWORD=password
|
- MYSQL_ROOT_PASSWORD=password
|
||||||
- MYSQL_PASSWORD=password
|
- MYSQL_PASSWORD=password
|
||||||
@@ -24,7 +26,7 @@ services:
|
|||||||
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8080:80
|
- 127.0.0.1:8080:80
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- db
|
- db
|
||||||
@@ -87,7 +89,7 @@ services:
|
|||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
|
||||||
# Vector sync configuration (ADR-007)
|
# Vector sync configuration (ADR-007)
|
||||||
- VECTOR_SYNC_ENABLED=true
|
#- VECTOR_SYNC_ENABLED=true
|
||||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
@@ -135,15 +137,24 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Multi-user BasicAuth pass-through mode (ADR-020)
|
# Multi-user BasicAuth pass-through mode (ADR-020)
|
||||||
- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
- ENABLE_MULTI_USER_BASIC_AUTH=true
|
- ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
#- ENABLE_OFFLINE_ACCESS=true
|
||||||
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
|
||||||
# Token storage (required for middleware initialization)
|
# Token storage (required for middleware initialization)
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
# Vector sync disabled (stateless pass-through mode)
|
- VECTOR_SYNC_ENABLED=true
|
||||||
- VECTOR_SYNC_ENABLED=false
|
- 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
|
# NO admin credentials - credentials come from client Authorization header
|
||||||
volumes:
|
volumes:
|
||||||
@@ -169,7 +180,8 @@ services:
|
|||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||||
|
|
||||||
# Refresh token storage (ADR-002 Tier 1)
|
# Refresh token storage (ADR-002 Tier 1)
|
||||||
- ENABLE_OFFLINE_ACCESS=true
|
#- ENABLE_OFFLINE_ACCESS=true
|
||||||
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
@@ -178,7 +190,8 @@ services:
|
|||||||
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
||||||
|
|
||||||
# Vector sync configuration (ADR-007)
|
# Vector sync configuration (ADR-007)
|
||||||
- VECTOR_SYNC_ENABLED=true
|
- ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
#- VECTOR_SYNC_ENABLED=true
|
||||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
@@ -246,7 +259,8 @@ services:
|
|||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
||||||
|
|
||||||
# Refresh token storage (ADR-002 Tier 1 & 2)
|
# Refresh token storage (ADR-002 Tier 1 & 2)
|
||||||
- ENABLE_OFFLINE_ACCESS=true
|
#- ENABLE_OFFLINE_ACCESS=true
|
||||||
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
|||||||
@@ -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=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Users must understand:
|
||||||
|
- Semantic search requires background token storage (ENABLE_OFFLINE_ACCESS)
|
||||||
|
- Background token storage requires encryption keys
|
||||||
|
- The relationship between ENABLE_OFFLINE_ACCESS and VECTOR_SYNC_ENABLED
|
||||||
|
- Which deployment mode these settings will activate
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We consolidate overlapping functionality and add explicit mode selection while maintaining 100% backward compatibility.
|
||||||
|
|
||||||
|
### 1. Automatic Dependency Resolution
|
||||||
|
|
||||||
|
**Make ENABLE_SEMANTIC_SEARCH the primary control** that automatically enables required dependencies:
|
||||||
|
|
||||||
|
**New behavior**:
|
||||||
|
```python
|
||||||
|
@property
|
||||||
|
def enable_background_operations(self) -> bool:
|
||||||
|
"""Background operations - auto-enabled by semantic search in multi-user modes."""
|
||||||
|
# Check new names first
|
||||||
|
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
|
||||||
|
# Fall back to old name with deprecation warning
|
||||||
|
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
|
||||||
|
# Auto-enable if semantic search needs it
|
||||||
|
auto_enabled = self.enable_semantic_search and self.is_multi_user_mode()
|
||||||
|
|
||||||
|
return explicit or legacy or auto_enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enable_semantic_search(self) -> bool:
|
||||||
|
"""Semantic search - renamed from VECTOR_SYNC_ENABLED."""
|
||||||
|
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
|
||||||
|
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
|
||||||
|
return new_value or old_value
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result**: Users set `ENABLE_SEMANTIC_SEARCH=true` and the system automatically enables background token storage when needed.
|
||||||
|
|
||||||
|
### 2. Explicit Mode Selection (Optional)
|
||||||
|
|
||||||
|
Add `MCP_DEPLOYMENT_MODE` environment variable to remove detection ambiguity:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Optional: Explicitly declare deployment mode
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
|
# Valid values: single_user_basic, multi_user_basic,
|
||||||
|
# oauth_single_audience, oauth_token_exchange, smithery
|
||||||
|
```
|
||||||
|
|
||||||
|
**Detection logic**:
|
||||||
|
1. If `MCP_DEPLOYMENT_MODE` is set → validate and use it
|
||||||
|
2. Otherwise → use priority-based auto-detection (existing behavior)
|
||||||
|
3. Validate explicit mode doesn't conflict with detected mode
|
||||||
|
|
||||||
|
### 3. Simplified User Experience
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```bash
|
||||||
|
# Multi-user OAuth with semantic search
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_OFFLINE_ACCESS=true # Confusing
|
||||||
|
VECTOR_SYNC_ENABLED=true # Why both?
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**After**:
|
||||||
|
```bash
|
||||||
|
# Multi-user OAuth with semantic search
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background ops
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- 2 fewer variables to understand/set
|
||||||
|
- Clear intent ("I want semantic search")
|
||||||
|
- Explicit mode declaration (optional)
|
||||||
|
- All existing configs continue working
|
||||||
|
|
||||||
|
### 4. Variable Naming Strategy
|
||||||
|
|
||||||
|
**Deprecated (but still functional)**:
|
||||||
|
- `ENABLE_OFFLINE_ACCESS` → Renamed to `ENABLE_BACKGROUND_OPERATIONS`
|
||||||
|
- `VECTOR_SYNC_ENABLED` → Renamed to `ENABLE_SEMANTIC_SEARCH`
|
||||||
|
|
||||||
|
**No change needed**:
|
||||||
|
- `VECTOR_SYNC_SCAN_INTERVAL` - Implementation tuning parameter (keep as-is)
|
||||||
|
- `VECTOR_SYNC_PROCESSOR_WORKERS` - Implementation tuning parameter (keep as-is)
|
||||||
|
- `VECTOR_SYNC_QUEUE_MAX_SIZE` - Implementation tuning parameter (keep as-is)
|
||||||
|
|
||||||
|
**Rationale**: Only rename user-facing feature flags, not internal tuning parameters.
|
||||||
|
|
||||||
|
### 5. Backward Compatibility
|
||||||
|
|
||||||
|
**Support both old and new names for minimum 2 major versions**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@property
|
||||||
|
def enable_semantic_search(self) -> bool:
|
||||||
|
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
|
||||||
|
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
|
||||||
|
|
||||||
|
if new_value and old_value:
|
||||||
|
logger.warning(
|
||||||
|
"Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. "
|
||||||
|
"Using ENABLE_SEMANTIC_SEARCH. VECTOR_SYNC_ENABLED is deprecated."
|
||||||
|
)
|
||||||
|
|
||||||
|
if old_value and not new_value:
|
||||||
|
logger.warning(
|
||||||
|
"VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_value or old_value
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deprecation timeline**:
|
||||||
|
- v0.6.0: Add new variables, deprecate old ones (both work with warnings)
|
||||||
|
- v1.0.0: Remove old variables (breaking change, well-announced)
|
||||||
|
- Minimum 2 major versions of support (12+ months)
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
1. **Reduced cognitive load**: Users set `ENABLE_SEMANTIC_SEARCH=true` instead of understanding internal dependencies
|
||||||
|
2. **Clearer intent**: Variable names reflect user-facing features, not implementation details
|
||||||
|
3. **Explicit mode control**: `MCP_DEPLOYMENT_MODE` removes detection ambiguity
|
||||||
|
4. **Better onboarding**: New users see simpler configuration in env.sample
|
||||||
|
5. **Improved error messages**: Validation can suggest "set MCP_DEPLOYMENT_MODE=X" instead of relying on implicit detection
|
||||||
|
6. **No breaking changes**: All existing configurations continue working
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
1. **Transition period complexity**: Both old and new names supported for 2+ versions
|
||||||
|
2. **Documentation burden**: All docs must be updated to show new approach
|
||||||
|
3. **Test coverage expansion**: Must test both old and new variable names in all modes
|
||||||
|
4. **Migration effort**: Existing deployments should eventually migrate (optional but recommended)
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
1. **Same functionality**: No new features, just better organization
|
||||||
|
2. **Same validation**: Underlying requirements unchanged (e.g., semantic search still needs Qdrant)
|
||||||
|
3. **Same performance**: No runtime performance impact
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Phase 1: Configuration Consolidation (v0.6.0)
|
||||||
|
|
||||||
|
**Files to modify**:
|
||||||
|
- `nextcloud_mcp_server/config.py` - Add property-based deprecation with auto-enablement
|
||||||
|
- `nextcloud_mcp_server/config_validators.py` - Simplify validation (semantic search no longer requires explicit background operations setting)
|
||||||
|
- `nextcloud_mcp_server/app.py` - Add informative logging for auto-enablement
|
||||||
|
- `tests/unit/test_config_validators.py` - Add auto-enablement tests
|
||||||
|
- `docs/configuration-migration-v2.md` - Create migration guide
|
||||||
|
|
||||||
|
**Key changes**:
|
||||||
|
1. `enable_background_operations` property auto-enables when `enable_semantic_search=true` in multi-user modes
|
||||||
|
2. `enable_semantic_search` property accepts both `ENABLE_SEMANTIC_SEARCH` and `VECTOR_SYNC_ENABLED`
|
||||||
|
3. Smart logging when auto-enablement occurs or deprecated variables used
|
||||||
|
4. Validation simplified to remove redundant requirements
|
||||||
|
|
||||||
|
### Phase 2: Explicit Mode Selection (v0.6.0)
|
||||||
|
|
||||||
|
**Files to modify**:
|
||||||
|
- `nextcloud_mcp_server/config.py` - Add `deployment_mode` field
|
||||||
|
- `nextcloud_mcp_server/config_validators.py` - Check explicit mode first, fall back to auto-detection
|
||||||
|
- `tests/unit/test_config_validators.py` - Test mode override and conflict detection
|
||||||
|
- `docs/configuration.md` - Document mode selection
|
||||||
|
|
||||||
|
**Key changes**:
|
||||||
|
1. Add `MCP_DEPLOYMENT_MODE` environment variable (optional)
|
||||||
|
2. Mode detection checks explicit mode first, then auto-detects
|
||||||
|
3. Validate explicit mode doesn't conflict with detected mode
|
||||||
|
4. Better error messages referencing explicit mode setting
|
||||||
|
|
||||||
|
### Phase 3: env.sample Reorganization (v0.6.0)
|
||||||
|
|
||||||
|
**Files to create/modify**:
|
||||||
|
- `env.sample` - Reorganize by deployment mode
|
||||||
|
- `env.sample.single-user` - Simplest config template
|
||||||
|
- `env.sample.oauth-multi-user` - Multi-user template showing consolidation
|
||||||
|
- `env.sample.oauth-advanced` - Token exchange mode template
|
||||||
|
- `README.md` - Update Quick Start to reference templates
|
||||||
|
|
||||||
|
**Key changes**:
|
||||||
|
1. Group related settings by deployment mode
|
||||||
|
2. Show simplified configuration (only essential variables)
|
||||||
|
3. Document automatic dependencies inline
|
||||||
|
4. Provide mode-specific quick-start templates
|
||||||
|
|
||||||
|
### Phase 4: Documentation Updates (v0.7.0)
|
||||||
|
|
||||||
|
**Files to modify**:
|
||||||
|
- `docs/configuration.md` - Lead with consolidated approach
|
||||||
|
- `docs/authentication.md` - Update mode guidance with `MCP_DEPLOYMENT_MODE`
|
||||||
|
- `docs/troubleshooting.md` - Add consolidation troubleshooting section
|
||||||
|
- `docs/configuration-migration-v2.md` - Expand with comprehensive examples
|
||||||
|
- `docs/ADR-020-deployment-modes-and-configuration-validation.md` - Update configuration matrix
|
||||||
|
- All other ADRs - Update variable references
|
||||||
|
|
||||||
|
**Key changes**:
|
||||||
|
1. Update all examples to use new variable names
|
||||||
|
2. Add before/after migration examples
|
||||||
|
3. Document automatic dependency resolution
|
||||||
|
4. Add mode selection decision tree diagram
|
||||||
|
|
||||||
|
## Validation Strategy
|
||||||
|
|
||||||
|
### Test Coverage Requirements
|
||||||
|
|
||||||
|
**Backward compatibility tests**:
|
||||||
|
- Old variable names still work (ENABLE_OFFLINE_ACCESS, VECTOR_SYNC_ENABLED)
|
||||||
|
- New variable names work (ENABLE_BACKGROUND_OPERATIONS, ENABLE_SEMANTIC_SEARCH)
|
||||||
|
- Setting both old and new triggers deprecation warning but works correctly
|
||||||
|
- All 41 existing config validation tests pass
|
||||||
|
|
||||||
|
**Auto-enablement tests**:
|
||||||
|
- `ENABLE_SEMANTIC_SEARCH=true` in OAuth mode → `enable_background_operations=true`
|
||||||
|
- `ENABLE_SEMANTIC_SEARCH=true` in single-user mode → `enable_background_operations=false` (not needed)
|
||||||
|
- `ENABLE_SEMANTIC_SEARCH=false` → `enable_background_operations=false` (unless explicitly set)
|
||||||
|
|
||||||
|
**Mode selection tests**:
|
||||||
|
- `MCP_DEPLOYMENT_MODE=oauth_single_audience` → mode correctly detected
|
||||||
|
- `MCP_DEPLOYMENT_MODE` conflicts with detected mode → validation error
|
||||||
|
- No `MCP_DEPLOYMENT_MODE` → auto-detection works as before
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
**Immediate** (v0.6.0 release):
|
||||||
|
- Zero breaking changes in existing deployments
|
||||||
|
- All 41 config validation tests pass
|
||||||
|
- New users report clearer configuration process
|
||||||
|
|
||||||
|
**Medium-term** (6 months after v0.6.0):
|
||||||
|
- 80% of new deployments use new variable names
|
||||||
|
- Mode selection errors decrease by 50%
|
||||||
|
- Support requests about configuration decrease
|
||||||
|
|
||||||
|
**Long-term** (12+ months):
|
||||||
|
- 90% of deployments migrated to new names
|
||||||
|
- Old variable names can be safely removed in v1.0.0
|
||||||
|
- Configuration-related issues in issue tracker decrease
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Alternative 1: Just Rename Variables
|
||||||
|
|
||||||
|
**Rejected**: User feedback: "There's no reason to just rename variables without consolidating functionality"
|
||||||
|
|
||||||
|
This would make names clearer but wouldn't reduce the number of variables users need to set. The real problem is requiring users to set both ENABLE_OFFLINE_ACCESS and VECTOR_SYNC_ENABLED when they just want semantic search.
|
||||||
|
|
||||||
|
### Alternative 2: Remove ENABLE_OFFLINE_ACCESS Entirely
|
||||||
|
|
||||||
|
**Rejected**: Advanced users need background operations without semantic search
|
||||||
|
|
||||||
|
Some deployments might want background token storage for future features (background Deck sync, background Calendar sync, etc.) without enabling semantic search. Keeping ENABLE_BACKGROUND_OPERATIONS (renamed) allows this.
|
||||||
|
|
||||||
|
### Alternative 3: Always Auto-Enable Background Operations
|
||||||
|
|
||||||
|
**Rejected**: Single-user mode doesn't need background token storage
|
||||||
|
|
||||||
|
Auto-enablement is only needed in multi-user modes. Single-user mode uses a shared client with BasicAuth, so background token storage is unnecessary. Always enabling it would waste resources and create confusing log messages.
|
||||||
|
|
||||||
|
### Alternative 4: Require All New Names Immediately
|
||||||
|
|
||||||
|
**Rejected**: Breaking change would affect all existing deployments
|
||||||
|
|
||||||
|
Forcing migration to new variable names in v0.6.0 would break every existing deployment. Supporting both old and new names with deprecation warnings provides a smooth migration path.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-020: Deployment Modes and Configuration Validation](ADR-020-deployment-modes-and-configuration-validation.md)
|
||||||
|
- [ADR-002: Vector Sync Authentication](ADR-002-vector-sync-authentication.md)
|
||||||
|
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md)
|
||||||
|
- [Issue: Configuration complexity for multi-user semantic search](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/XXX)
|
||||||
|
|
||||||
|
## Migration Examples
|
||||||
|
|
||||||
|
### Example 1: Single-User BasicAuth with Semantic Search
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
QDRANT_LOCATION=:memory:
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (optional migration):
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # Renamed
|
||||||
|
QDRANT_LOCATION=:memory:
|
||||||
|
# Note: Background operations NOT auto-enabled (not needed in single-user mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Multi-User OAuth with Semantic Search
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (simplified):
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
# Note: ENABLE_OFFLINE_ACCESS no longer needed (auto-enabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Multi-User OAuth WITHOUT Semantic Search
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_OFFLINE_ACCESS=true # For future background features
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (optional migration):
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true # Renamed for clarity
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
```
|
||||||
@@ -140,6 +140,93 @@ Basic Authentication uses username and password credentials directly.
|
|||||||
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
|
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
|
||||||
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
|
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
|
||||||
|
|
||||||
|
## Hybrid Authentication (Multi-User BasicAuth + OAuth)
|
||||||
|
|
||||||
|
When running in multi-user BasicAuth mode with `ENABLE_OFFLINE_ACCESS=true`, the server operates in **hybrid authentication mode**. This provides the simplicity of BasicAuth for normal operations with the security of OAuth for administrative functions.
|
||||||
|
|
||||||
|
### Authentication Domains
|
||||||
|
|
||||||
|
**MCP Operations** (Tools, Resources):
|
||||||
|
- **Auth Method**: BasicAuth (HTTP Basic username/password)
|
||||||
|
- **Characteristics**:
|
||||||
|
- Stateless - no token storage
|
||||||
|
- Simple configuration
|
||||||
|
- Direct credential validation against Nextcloud
|
||||||
|
- Credentials passed per-request in Authorization header
|
||||||
|
- **Used For**: MCP tool calls from Claude, MCP client operations
|
||||||
|
|
||||||
|
**Management APIs** (Webhooks, Admin UI):
|
||||||
|
- **Auth Method**: OAuth bearer tokens
|
||||||
|
- **Characteristics**:
|
||||||
|
- Per-user authorization via OAuth consent flow
|
||||||
|
- Refresh tokens stored for background operations
|
||||||
|
- Token validation via UnifiedTokenVerifier
|
||||||
|
- Explicit user consent required
|
||||||
|
- **Used For**: Astrolabe admin UI, webhook management, vector sync operations
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Enable multi-user BasicAuth
|
||||||
|
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
|
||||||
|
# Enable hybrid mode (OAuth provisioning for management APIs)
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
|
||||||
|
# Enable background sync (required for hybrid mode currently)
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
|
||||||
|
# Encryption key for refresh token storage
|
||||||
|
TOKEN_ENCRYPTION_KEY=<base64-encoded-key>
|
||||||
|
|
||||||
|
# Nextcloud connection
|
||||||
|
NEXTCLOUD_HOST=https://cloud.example.com
|
||||||
|
|
||||||
|
# OAuth credentials (optional - uses DCR if not set)
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Provisioning Flow
|
||||||
|
|
||||||
|
1. Admin opens Astrolabe admin settings in Nextcloud
|
||||||
|
2. Clicks "Authorize" to enable webhook management
|
||||||
|
3. Redirected to `/oauth/authorize-nextcloud` on MCP server
|
||||||
|
4. MCP server redirects to Nextcloud OAuth consent page
|
||||||
|
5. Admin grants OAuth consent (scopes: `openid`, `profile`, `offline_access`)
|
||||||
|
6. Redirected back to `/oauth/callback` on MCP server
|
||||||
|
7. MCP server stores refresh token (encrypted)
|
||||||
|
8. Admin can now manage webhooks from Astrolabe UI
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
- **Simple MCP client setup**: Use BasicAuth (no OAuth complexity for end users)
|
||||||
|
- **Secure background operations**: Webhooks use per-user OAuth tokens (no shared credentials)
|
||||||
|
- **Explicit authorization**: Admins must explicitly grant OAuth consent for webhook operations
|
||||||
|
- **Per-user isolation**: Each admin's webhook operations use their own refresh token
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
- **Two auth systems**: More complex server configuration than pure BasicAuth or OAuth
|
||||||
|
- **OAuth setup required**: Admins must complete OAuth flow before managing webhooks
|
||||||
|
- **Token storage**: Requires database and encryption key for refresh tokens
|
||||||
|
|
||||||
|
### Comparison
|
||||||
|
|
||||||
|
| Feature | Pure BasicAuth | Hybrid Mode | Pure OAuth |
|
||||||
|
|---------|---------------|-------------|------------|
|
||||||
|
| MCP Operations | BasicAuth | BasicAuth | OAuth Bearer Token |
|
||||||
|
| Management API | N/A | OAuth Bearer Token | OAuth Bearer Token |
|
||||||
|
| Webhook Operations | N/A | OAuth Refresh Token | OAuth Refresh Token |
|
||||||
|
| MCP Client Setup | Simple | Simple | Complex (PKCE flow) |
|
||||||
|
| Admin UI Auth | N/A | OAuth Consent | OAuth Login |
|
||||||
|
| Token Storage | None | Refresh tokens only | All tokens |
|
||||||
|
| Deployment Complexity | Low | Medium | High |
|
||||||
|
|
||||||
|
### See Also
|
||||||
|
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
|
||||||
|
- [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration
|
||||||
|
|
||||||
## Mode Detection
|
## Mode Detection
|
||||||
|
|
||||||
The server automatically detects the authentication mode:
|
The server automatically detects the authentication mode:
|
||||||
|
|||||||
@@ -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.
|
||||||
+129
-15
@@ -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.
|
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
|
## Quick Start
|
||||||
|
|
||||||
Create a `.env` file based on `env.sample`:
|
We provide mode-specific configuration templates for quick setup:
|
||||||
|
|
||||||
```bash
|
```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
|
cp env.sample .env
|
||||||
|
|
||||||
# Edit .env with your Nextcloud details
|
# Edit .env with your Nextcloud details
|
||||||
```
|
```
|
||||||
|
|
||||||
Then choose your authentication mode:
|
Then choose your deployment mode:
|
||||||
|
|
||||||
- [OAuth2/OIDC Configuration](#oauth2oidc-configuration) (Recommended)
|
- [Single-User BasicAuth](#single-user-basicauth-mode) - Simplest for personal instances
|
||||||
- [Basic Authentication Configuration](#basic-authentication-legacy)
|
- [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)
|
### 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
|
# .env file for OAuth with auto-registration
|
||||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||||
|
|
||||||
|
# Optional: Explicit mode declaration (recommended)
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
# Leave these EMPTY for OAuth mode
|
# Leave these EMPTY for OAuth mode
|
||||||
NEXTCLOUD_USERNAME=
|
NEXTCLOUD_USERNAME=
|
||||||
NEXTCLOUD_PASSWORD=
|
NEXTCLOUD_PASSWORD=
|
||||||
@@ -41,6 +101,9 @@ This minimal configuration uses dynamic client registration to automatically reg
|
|||||||
# .env file for OAuth with pre-configured client
|
# .env file for OAuth with pre-configured client
|
||||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
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)
|
# OAuth Client Credentials (optional - auto-registers if not provided)
|
||||||
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
|
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
|
||||||
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
||||||
@@ -110,8 +173,50 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
|
|||||||
|
|
||||||
## Semantic Search Configuration (Optional)
|
## 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.
|
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
|
### Qdrant Vector Database Modes
|
||||||
|
|
||||||
The server supports three Qdrant deployment 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
|
```dotenv
|
||||||
# No Qdrant configuration needed - defaults to :memory:
|
# No Qdrant configuration needed - defaults to :memory:
|
||||||
VECTOR_SYNC_ENABLED=true
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pros:**
|
**Pros:**
|
||||||
@@ -145,7 +250,7 @@ For single-instance deployments that need persistence without a separate Qdrant
|
|||||||
```dotenv
|
```dotenv
|
||||||
# Local persistent storage
|
# Local persistent storage
|
||||||
QDRANT_LOCATION=/app/data/qdrant # Or any writable path
|
QDRANT_LOCATION=/app/data/qdrant # Or any writable path
|
||||||
VECTOR_SYNC_ENABLED=true
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pros:**
|
**Pros:**
|
||||||
@@ -166,7 +271,7 @@ For production deployments with a dedicated Qdrant service:
|
|||||||
QDRANT_URL=http://qdrant:6333
|
QDRANT_URL=http://qdrant:6333
|
||||||
QDRANT_API_KEY=your-secret-api-key # Optional
|
QDRANT_API_KEY=your-secret-api-key # Optional
|
||||||
QDRANT_COLLECTION=nextcloud_content # Optional
|
QDRANT_COLLECTION=nextcloud_content # Optional
|
||||||
VECTOR_SYNC_ENABLED=true
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pros:**
|
**Pros:**
|
||||||
@@ -283,13 +388,15 @@ Solutions:
|
|||||||
- Data corruption in Qdrant
|
- Data corruption in Qdrant
|
||||||
- Confusing error messages during indexing
|
- Confusing error messages during indexing
|
||||||
|
|
||||||
### Vector Sync Configuration
|
### Background Indexing Configuration
|
||||||
|
|
||||||
Control background indexing behavior:
|
Control background indexing behavior:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
# Vector sync settings (ADR-007)
|
# Semantic search (ADR-007, ADR-021)
|
||||||
VECTOR_SYNC_ENABLED=true # Enable background indexing
|
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_SCAN_INTERVAL=300 # Scan interval in seconds (default: 5 minutes)
|
||||||
VECTOR_SYNC_PROCESSOR_WORKERS=3 # Concurrent indexing workers (default: 3)
|
VECTOR_SYNC_PROCESSOR_WORKERS=3 # Concurrent indexing workers (default: 3)
|
||||||
VECTOR_SYNC_QUEUE_MAX_SIZE=10000 # Max queued documents (default: 10000)
|
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)
|
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
|
### Embedding Service Configuration
|
||||||
|
|
||||||
The server uses an embedding service to generate vector representations. Two options are available:
|
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 |
|
| 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_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_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_API_KEY` | ⚠️ Optional | - | Qdrant API key (network mode only) |
|
||||||
| `QDRANT_COLLECTION` | ⚠️ Optional | `nextcloud_content` | Qdrant collection name |
|
| `QDRANT_COLLECTION` | ⚠️ Optional | Auto-generated | Qdrant collection name |
|
||||||
| `VECTOR_SYNC_ENABLED` | ⚠️ Optional | `false` | Enable background vector indexing |
|
|
||||||
| `VECTOR_SYNC_SCAN_INTERVAL` | ⚠️ Optional | `300` | Document scan interval (seconds) |
|
| `VECTOR_SYNC_SCAN_INTERVAL` | ⚠️ Optional | `300` | Document scan interval (seconds) |
|
||||||
| `VECTOR_SYNC_PROCESSOR_WORKERS` | ⚠️ Optional | `3` | Concurrent indexing workers |
|
| `VECTOR_SYNC_PROCESSOR_WORKERS` | ⚠️ Optional | `3` | Concurrent indexing workers |
|
||||||
| `VECTOR_SYNC_QUEUE_MAX_SIZE` | ⚠️ Optional | `10000` | Max queued documents |
|
| `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_SIZE` | ⚠️ Optional | `512` | Words per chunk for document embedding |
|
||||||
| `DOCUMENT_CHUNK_OVERLAP` | ⚠️ Optional | `50` | Overlapping words between chunks (must be < chunk size) |
|
| `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
|
### Docker Compose Example
|
||||||
|
|
||||||
Enable network mode Qdrant with docker-compose:
|
Enable network mode Qdrant with docker-compose:
|
||||||
@@ -392,7 +504,7 @@ services:
|
|||||||
mcp:
|
mcp:
|
||||||
environment:
|
environment:
|
||||||
- QDRANT_URL=http://qdrant:6333
|
- QDRANT_URL=http://qdrant:6333
|
||||||
- VECTOR_SYNC_ENABLED=true
|
- ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant:latest
|
image: qdrant/qdrant:latest
|
||||||
@@ -545,6 +657,7 @@ uv run nextcloud-mcp-server --no-oauth \
|
|||||||
|
|
||||||
## See Also
|
## 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 Quick Start](quickstart-oauth.md) - 5-minute OAuth setup for development
|
||||||
- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production
|
- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production
|
||||||
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in the MCP server
|
- [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
|
- [Running the Server](running.md) - Starting the server with different configurations
|
||||||
- [Troubleshooting](troubleshooting.md) - Common configuration issues
|
- [Troubleshooting](troubleshooting.md) - Common configuration issues
|
||||||
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting
|
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting
|
||||||
|
- [ADR-021](ADR-021-configuration-consolidation.md) - Configuration consolidation architecture decision
|
||||||
|
|||||||
@@ -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.
|
> **OAuth-specific issues?** See the dedicated [OAuth Troubleshooting Guide](oauth-troubleshooting.md) for OAuth authentication problems, OIDC discovery issues, token validation failures, and more.
|
||||||
|
|
||||||
|
> **Upgrading from v0.57.x?** See the [Configuration Migration Guide](configuration-migration-v2.md) for help with new variable names.
|
||||||
|
|
||||||
|
## Configuration Issues (v0.58.0+)
|
||||||
|
|
||||||
|
### Issue: Deprecation warning for VECTOR_SYNC_ENABLED
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
WARNING: VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** You're using the old variable name from v0.57.x.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# In your .env file, replace:
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
|
||||||
|
# With:
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Configuration Migration Guide](configuration-migration-v2.md) for complete migration instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Deprecation warning for ENABLE_OFFLINE_ACCESS
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
WARNING: ENABLE_OFFLINE_ACCESS is deprecated. Please use ENABLE_BACKGROUND_OPERATIONS instead.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** You're using the old variable name from v0.57.x.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
**If you have semantic search enabled:**
|
||||||
|
```bash
|
||||||
|
# In multi-user modes, you can remove ENABLE_OFFLINE_ACCESS entirely!
|
||||||
|
# ENABLE_SEMANTIC_SEARCH automatically enables background operations
|
||||||
|
|
||||||
|
# Before (v0.57.x):
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
|
||||||
|
# After (v0.58.0+):
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # This is all you need!
|
||||||
|
```
|
||||||
|
|
||||||
|
**If you only want background operations (no semantic search):**
|
||||||
|
```bash
|
||||||
|
# Replace:
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
|
||||||
|
# With:
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: "Invalid MCP_DEPLOYMENT_MODE"
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
ValueError: Invalid MCP_DEPLOYMENT_MODE: 'oauth'. Valid values: single_user_basic, multi_user_basic, oauth_single_audience, oauth_token_exchange, smithery
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Invalid value for `MCP_DEPLOYMENT_MODE`.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
Use one of the valid mode values:
|
||||||
|
```bash
|
||||||
|
# Correct values:
|
||||||
|
MCP_DEPLOYMENT_MODE=single_user_basic # Single-user with username/password
|
||||||
|
MCP_DEPLOYMENT_MODE=multi_user_basic # Multi-user BasicAuth
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience # OAuth (recommended)
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_token_exchange # OAuth with token exchange
|
||||||
|
MCP_DEPLOYMENT_MODE=smithery # Smithery deployment
|
||||||
|
```
|
||||||
|
|
||||||
|
Or remove `MCP_DEPLOYMENT_MODE` to use automatic detection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Missing TOKEN_ENCRYPTION_KEY when semantic search enabled
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
Error: [oauth_single_audience] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** In multi-user modes, semantic search automatically enables background operations, which require encrypted token storage.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
Generate an encryption key and add required token storage configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate encryption key
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
|
||||||
|
# Add to .env:
|
||||||
|
TOKEN_ENCRYPTION_KEY=<generated-key>
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id # Required for app password retrieval
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this happens:**
|
||||||
|
- v0.58.0+ automatically enables background operations when `ENABLE_SEMANTIC_SEARCH=true` in multi-user modes
|
||||||
|
- Background operations need encrypted refresh token storage
|
||||||
|
- This simplifies configuration but requires the encryption infrastructure
|
||||||
|
|
||||||
|
See [Configuration Guide - Semantic Search](configuration.md#semantic-search-configuration-optional) for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Both old and new variable names set
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
WARNING: Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. Using ENABLE_SEMANTIC_SEARCH.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** You have both the old and new variable names in your configuration.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
Remove the old variable name:
|
||||||
|
```bash
|
||||||
|
# Remove this line:
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
|
||||||
|
# Keep this line:
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will use the new name and ignore the old one, but it's cleaner to remove the old variable entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## OAuth Issues (Quick Reference)
|
## OAuth Issues (Quick Reference)
|
||||||
|
|
||||||
### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable"
|
### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable"
|
||||||
|
|||||||
+225
-192
@@ -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=
|
NEXTCLOUD_HOST=
|
||||||
|
|
||||||
# ===== AUTHENTICATION MODE =====
|
# ============================================
|
||||||
# Choose ONE of the following:
|
# SINGLE-USER BASICAUTH MODE
|
||||||
|
# ============================================
|
||||||
# Option 1: OAuth2/OIDC (RECOMMENDED - More Secure)
|
# Simplest deployment - one user, credentials in environment
|
||||||
# - Requires Nextcloud OIDC app installed and configured
|
# Use for: Personal instances, local development, testing
|
||||||
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
|
#
|
||||||
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
|
# Required:
|
||||||
# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB)
|
|
||||||
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
|
|
||||||
NEXTCLOUD_OIDC_CLIENT_ID=
|
|
||||||
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
|
||||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
|
||||||
|
|
||||||
# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens)
|
|
||||||
# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens
|
|
||||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
|
||||||
#TOKEN_ENCRYPTION_KEY=
|
|
||||||
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
|
|
||||||
#TOKEN_STORAGE_DB=/app/data/tokens.db
|
|
||||||
|
|
||||||
# ===== ADR-004 PROGRESSIVE CONSENT CONFIGURATION =====
|
|
||||||
# Enable Progressive Consent mode (dual OAuth flows)
|
|
||||||
# When enabled: Flow 1 for client auth, Flow 2 for Nextcloud resource access
|
|
||||||
# When disabled: Uses existing hybrid flow (backward compatible)
|
|
||||||
|
|
||||||
# MCP Server OAuth Client Configuration
|
|
||||||
# The MCP server's own OAuth client credentials for Flow 2
|
|
||||||
# If not set, will use dynamic client registration
|
|
||||||
#MCP_SERVER_CLIENT_ID=
|
|
||||||
#MCP_SERVER_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Allowed MCP Client IDs (comma-separated list)
|
|
||||||
# Client IDs that are allowed to authenticate in Flow 1
|
|
||||||
# Examples: claude-desktop,continue-dev,zed-editor
|
|
||||||
#ALLOWED_MCP_CLIENTS=claude-desktop,continue-dev,zed-editor
|
|
||||||
|
|
||||||
# Token cache configuration for Token Broker Service
|
|
||||||
# Cache TTL in seconds (default: 300 = 5 minutes)
|
|
||||||
#TOKEN_CACHE_TTL=300
|
|
||||||
# Early refresh threshold in seconds (default: 30)
|
|
||||||
#TOKEN_CACHE_EARLY_REFRESH=30
|
|
||||||
|
|
||||||
# Option 2: Basic Authentication (LEGACY - Less Secure)
|
|
||||||
# - Requires username and password
|
|
||||||
# - Credentials stored in environment variables
|
|
||||||
# - Use only for backward compatibility or if OAuth unavailable
|
|
||||||
# - If these are set, OAuth mode is disabled
|
|
||||||
NEXTCLOUD_USERNAME=
|
NEXTCLOUD_USERNAME=
|
||||||
NEXTCLOUD_PASSWORD=
|
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)
|
# Cookie security (browser UI)
|
||||||
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
||||||
# Set explicitly for non-standard setups
|
|
||||||
#COOKIE_SECURE=true
|
#COOKIE_SECURE=true
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Document Processing Configuration
|
# DEPRECATED VARIABLES (Backward Compatibility)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Enable document processing (PDF, DOCX, images, etc.)
|
# These variables still work but will be removed in v1.0.0
|
||||||
# Set to false to disable all document processing
|
# Please migrate to new names:
|
||||||
ENABLE_DOCUMENT_PROCESSING=false
|
#
|
||||||
|
# Old Name → New Name
|
||||||
# Default processor to use when multiple are available
|
# VECTOR_SYNC_ENABLED → ENABLE_SEMANTIC_SEARCH
|
||||||
# Options: unstructured, tesseract, custom
|
# ENABLE_OFFLINE_ACCESS → ENABLE_BACKGROUND_OPERATIONS
|
||||||
DOCUMENT_PROCESSOR=unstructured
|
#
|
||||||
|
# Migration is optional - both old and new names work
|
||||||
# ============================================
|
# Deprecation warnings will be logged when old names are used
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -11,11 +11,11 @@ The PHP app obtains tokens through PKCE flow and uses them to access these endpo
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
@@ -55,6 +55,22 @@ async def validate_token_and_get_user(
|
|||||||
) -> tuple[str, dict[str, Any]]:
|
) -> tuple[str, dict[str, Any]]:
|
||||||
"""Validate OAuth bearer token and extract user ID.
|
"""Validate OAuth bearer token and extract user ID.
|
||||||
|
|
||||||
|
Uses verify_token_for_management_api which accepts any valid Nextcloud OIDC
|
||||||
|
token (not just MCP-audience tokens). This is needed because Astrolabe
|
||||||
|
(NC PHP app) uses its own OAuth client, separate from MCP server's client.
|
||||||
|
|
||||||
|
Security Model:
|
||||||
|
~~~~~~~~~~~~~~~
|
||||||
|
- **Authentication** (this function): Verifies token is cryptographically valid
|
||||||
|
and extracts user identity from the `sub` claim.
|
||||||
|
- **Authorization** (calling endpoints): Each endpoint MUST verify that the
|
||||||
|
authenticated user owns the requested resource. For example:
|
||||||
|
- GET /users/{user_id}/session: Checks token_user_id == path_user_id (403 if mismatch)
|
||||||
|
- POST /users/{user_id}/revoke: Checks token_user_id == path_user_id (403 if mismatch)
|
||||||
|
|
||||||
|
This separation ensures that even without audience validation, users can only
|
||||||
|
access their own resources. Cross-user access is blocked at the authorization layer.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: Starlette request with Authorization header
|
request: Starlette request with Authorization header
|
||||||
|
|
||||||
@@ -72,9 +88,10 @@ async def validate_token_and_get_user(
|
|||||||
# Note: This is set in app.py starlette_lifespan for OAuth mode
|
# Note: This is set in app.py starlette_lifespan for OAuth mode
|
||||||
token_verifier = request.app.state.oauth_context["token_verifier"]
|
token_verifier = request.app.state.oauth_context["token_verifier"]
|
||||||
|
|
||||||
# Validate token (handles both JWT and opaque tokens)
|
# Validate token for management API (handles both JWT and opaque tokens)
|
||||||
# verify_token returns AccessToken object or None
|
# Uses verify_token_for_management_api which accepts any valid Nextcloud token
|
||||||
access_token = await token_verifier.verify_token(token)
|
# without requiring MCP audience - needed for Astrolabe integration (ADR-018)
|
||||||
|
access_token = await token_verifier.verify_token_for_management_api(token)
|
||||||
|
|
||||||
if not access_token:
|
if not access_token:
|
||||||
raise ValueError("Token validation failed")
|
raise ValueError("Token validation failed")
|
||||||
@@ -182,14 +199,23 @@ async def get_server_status(request: Request) -> JSONResponse:
|
|||||||
# Calculate uptime
|
# Calculate uptime
|
||||||
uptime_seconds = int(time.time() - _server_start_time)
|
uptime_seconds = int(time.time() - _server_start_time)
|
||||||
|
|
||||||
# Determine auth mode
|
# Determine auth mode using proper mode detection
|
||||||
nextcloud_username = os.getenv("NEXTCLOUD_USERNAME")
|
from nextcloud_mcp_server.config_validators import AuthMode, detect_auth_mode
|
||||||
nextcloud_password = os.getenv("NEXTCLOUD_PASSWORD")
|
|
||||||
|
|
||||||
if nextcloud_username and nextcloud_password:
|
mode = detect_auth_mode(settings)
|
||||||
auth_mode = "basic"
|
|
||||||
else:
|
# 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"
|
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 = {
|
response_data = {
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
@@ -199,6 +225,10 @@ async def get_server_status(request: Request) -> JSONResponse:
|
|||||||
"management_api_version": "1.0",
|
"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
|
# Include OIDC configuration if in OAuth mode
|
||||||
if auth_mode == "oauth":
|
if auth_mode == "oauth":
|
||||||
# Provide IdP discovery information for NC PHP app
|
# Provide IdP discovery information for NC PHP app
|
||||||
@@ -334,11 +364,12 @@ async def get_user_session(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check if offline access is enabled
|
# Check if offline access is enabled
|
||||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
||||||
"true",
|
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
||||||
"1",
|
from nextcloud_mcp_server.config import get_settings
|
||||||
"yes",
|
|
||||||
)
|
settings = get_settings()
|
||||||
|
enable_offline_access = settings.enable_offline_access
|
||||||
|
|
||||||
if not enable_offline_access:
|
if not enable_offline_access:
|
||||||
# Offline access disabled - return minimal session info
|
# Offline access disabled - return minimal session info
|
||||||
@@ -500,8 +531,6 @@ async def get_installed_apps(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import httpx
|
|
||||||
|
|
||||||
# Get Bearer token from request
|
# Get Bearer token from request
|
||||||
token = extract_bearer_token(request)
|
token = extract_bearer_token(request)
|
||||||
if not token:
|
if not token:
|
||||||
@@ -572,8 +601,6 @@ async def list_webhooks(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import httpx
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||||
|
|
||||||
# Get Bearer token from request
|
# Get Bearer token from request
|
||||||
@@ -639,8 +666,6 @@ async def create_webhook(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import httpx
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||||
|
|
||||||
# Parse request body
|
# Parse request body
|
||||||
@@ -717,8 +742,6 @@ async def delete_webhook(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import httpx
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||||
|
|
||||||
# Get webhook_id from path parameter
|
# Get webhook_id from path parameter
|
||||||
|
|||||||
+590
-251
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
@@ -301,25 +302,6 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
token_endpoint = discovery["token_endpoint"]
|
token_endpoint = discovery["token_endpoint"]
|
||||||
|
|
||||||
# Rewrite token_endpoint from public URL to internal Docker URL
|
|
||||||
# Discovery document returns public URLs (e.g., http://localhost:8080/...)
|
|
||||||
# but server-side requests must use internal Docker network (e.g., http://app:80/...)
|
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
|
||||||
if public_issuer:
|
|
||||||
from urllib.parse import urlparse as parse_url
|
|
||||||
|
|
||||||
internal_host = oauth_config["nextcloud_host"]
|
|
||||||
internal_parsed = parse_url(internal_host)
|
|
||||||
token_parsed = parse_url(token_endpoint)
|
|
||||||
public_parsed = parse_url(public_issuer)
|
|
||||||
|
|
||||||
if token_parsed.hostname == public_parsed.hostname:
|
|
||||||
# Replace public URL with internal Docker URL
|
|
||||||
token_endpoint = f"{internal_parsed.scheme}://{internal_parsed.netloc}{token_parsed.path}"
|
|
||||||
logger.info(
|
|
||||||
f"Rewrote token endpoint to internal URL: {token_endpoint}"
|
|
||||||
)
|
|
||||||
|
|
||||||
token_params = {
|
token_params = {
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"code": code,
|
"code": code,
|
||||||
@@ -400,8 +382,6 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
refresh_expires_in = token_data.get("refresh_expires_in")
|
refresh_expires_in = token_data.get("refresh_expires_in")
|
||||||
refresh_expires_at = None
|
refresh_expires_at = None
|
||||||
if refresh_expires_in:
|
if refresh_expires_in:
|
||||||
import time
|
|
||||||
|
|
||||||
refresh_expires_at = int(time.time()) + refresh_expires_in
|
refresh_expires_at = int(time.time()) + refresh_expires_in
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
|
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Handles OAuth flows with Keycloak as the identity provider, including:
|
|||||||
- Integration with RefreshTokenStorage
|
- Integration with RefreshTokenStorage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -155,7 +156,6 @@ class KeycloakOAuthClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (code_verifier, code_challenge)
|
Tuple of (code_verifier, code_challenge)
|
||||||
"""
|
"""
|
||||||
import base64
|
|
||||||
|
|
||||||
# Generate code verifier (43-128 characters)
|
# Generate code verifier (43-128 characters)
|
||||||
code_verifier = secrets.token_urlsafe(32)
|
code_verifier = secrets.token_urlsafe(32)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
@@ -521,8 +522,6 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
refresh_expires_in = token_data.get("refresh_expires_in")
|
refresh_expires_in = token_data.get("refresh_expires_in")
|
||||||
refresh_expires_at = None
|
refresh_expires_at = None
|
||||||
if refresh_expires_in:
|
if refresh_expires_in:
|
||||||
import time
|
|
||||||
|
|
||||||
refresh_expires_at = int(time.time()) + refresh_expires_in
|
refresh_expires_at = int(time.time()) + refresh_expires_in
|
||||||
logger.info(f" refresh_expires_in: {refresh_expires_in}s")
|
logger.info(f" refresh_expires_in: {refresh_expires_in}s")
|
||||||
logger.info(f" refresh_expires_at: {refresh_expires_at}")
|
logger.info(f" refresh_expires_at: {refresh_expires_at}")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
import jwt
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.types import ErrorData
|
from mcp.types import ErrorData
|
||||||
@@ -78,8 +79,6 @@ def require_provisioning(func: Callable) -> Callable:
|
|||||||
user_id = None
|
user_id = None
|
||||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||||
try:
|
try:
|
||||||
import jwt
|
|
||||||
|
|
||||||
token = ctx.authorization.token
|
token = ctx.authorization.token
|
||||||
payload = jwt.decode(token, options={"verify_signature": False})
|
payload = jwt.decode(token, options={"verify_signature": False})
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
@@ -163,8 +162,6 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
|
|||||||
# Get user_id from authorization token
|
# Get user_id from authorization token
|
||||||
user_id = None
|
user_id = None
|
||||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||||
import jwt
|
|
||||||
|
|
||||||
token = ctx.authorization.token
|
token = ctx.authorization.token
|
||||||
payload = jwt.decode(token, options={"verify_signature": False})
|
payload = jwt.decode(token, options={"verify_signature": False})
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Scope-based authorization for MCP tools."""
|
"""Scope-based authorization for MCP tools."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
@@ -131,9 +130,12 @@ def require_scopes(*required_scopes: str):
|
|||||||
required_scopes_set = set(required_scopes)
|
required_scopes_set = set(required_scopes)
|
||||||
|
|
||||||
# Check if offline access is enabled
|
# Check if offline access is enabled
|
||||||
enable_offline_access = (
|
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
||||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
||||||
)
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
enable_offline_access = settings.enable_offline_access
|
||||||
|
|
||||||
# In offline access mode, check if Nextcloud scopes require provisioning
|
# In offline access mode, check if Nextcloud scopes require provisioning
|
||||||
if enable_offline_access:
|
if enable_offline_access:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Sensitive data (tokens, secrets) is encrypted at rest using Fernet symmetric enc
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -830,7 +831,6 @@ class RefreshTokenStorage:
|
|||||||
resource_id: Resource identifier
|
resource_id: Resource identifier
|
||||||
auth_method: Authentication method used
|
auth_method: Authentication method used
|
||||||
"""
|
"""
|
||||||
import socket
|
|
||||||
|
|
||||||
hostname = socket.gethostname()
|
hostname = socket.gethostname()
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
|
|||||||
@@ -168,37 +168,6 @@ class TokenBrokerService:
|
|||||||
self._oidc_config = response.json()
|
self._oidc_config = response.json()
|
||||||
return self._oidc_config
|
return self._oidc_config
|
||||||
|
|
||||||
def _rewrite_token_endpoint(self, token_endpoint: str) -> str:
|
|
||||||
"""Rewrite token endpoint from public URL to internal Docker URL.
|
|
||||||
|
|
||||||
OIDC discovery documents return public URLs (e.g., http://localhost:8080/...)
|
|
||||||
but server-side requests must use internal Docker network (e.g., http://app:80/...).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token_endpoint: Token endpoint URL from discovery document
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rewritten URL using internal Docker host
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
|
||||||
if not public_issuer:
|
|
||||||
return token_endpoint
|
|
||||||
|
|
||||||
internal_parsed = urlparse(self.nextcloud_host)
|
|
||||||
token_parsed = urlparse(token_endpoint)
|
|
||||||
public_parsed = urlparse(public_issuer)
|
|
||||||
|
|
||||||
if token_parsed.hostname == public_parsed.hostname:
|
|
||||||
# Replace public URL with internal Docker URL
|
|
||||||
rewritten = f"{internal_parsed.scheme}://{internal_parsed.netloc}{token_parsed.path}"
|
|
||||||
logger.info(f"Rewrote token endpoint: {token_endpoint} -> {rewritten}")
|
|
||||||
return rewritten
|
|
||||||
|
|
||||||
return token_endpoint
|
|
||||||
|
|
||||||
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
|
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Get a valid Nextcloud access token for the user.
|
Get a valid Nextcloud access token for the user.
|
||||||
@@ -407,7 +376,7 @@ class TokenBrokerService:
|
|||||||
Tuple of (access_token, expires_in_seconds)
|
Tuple of (access_token, expires_in_seconds)
|
||||||
"""
|
"""
|
||||||
config = await self._get_oidc_config()
|
config = await self._get_oidc_config()
|
||||||
token_endpoint = self._rewrite_token_endpoint(config["token_endpoint"])
|
token_endpoint = config["token_endpoint"]
|
||||||
|
|
||||||
client = await self._get_http_client()
|
client = await self._get_http_client()
|
||||||
|
|
||||||
@@ -477,7 +446,7 @@ class TokenBrokerService:
|
|||||||
Tuple of (access_token, expires_in_seconds)
|
Tuple of (access_token, expires_in_seconds)
|
||||||
"""
|
"""
|
||||||
config = await self._get_oidc_config()
|
config = await self._get_oidc_config()
|
||||||
token_endpoint = self._rewrite_token_endpoint(config["token_endpoint"])
|
token_endpoint = config["token_endpoint"]
|
||||||
|
|
||||||
client = await self._get_http_client()
|
client = await self._get_http_client()
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,71 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
# Both modes do the same validation (MCP audience only)
|
# Both modes do the same validation (MCP audience only)
|
||||||
return await self._verify_mcp_audience(token)
|
return await self._verify_mcp_audience(token)
|
||||||
|
|
||||||
|
async def verify_token_for_management_api(self, token: str) -> AccessToken | None:
|
||||||
|
"""
|
||||||
|
Verify token for management API access (ADR-018 NC PHP app integration).
|
||||||
|
|
||||||
|
This verification accepts ANY valid Nextcloud OIDC token, not just tokens
|
||||||
|
with MCP server audience. This is needed because:
|
||||||
|
- Astrolabe (NC PHP app) uses its own OAuth client with Nextcloud OIDC
|
||||||
|
- Tokens from Astrolabe have Astrolabe's client_id as audience
|
||||||
|
- MCP server's management API should accept these tokens
|
||||||
|
|
||||||
|
Security Model:
|
||||||
|
~~~~~~~~~~~~~~~~
|
||||||
|
This relaxed audience validation is secure because:
|
||||||
|
|
||||||
|
1. **Authentication layer** (this method):
|
||||||
|
- Verifies token signature against Nextcloud's JWKS (cryptographic proof)
|
||||||
|
- Verifies token is not expired
|
||||||
|
- Extracts user identity from validated token claims
|
||||||
|
|
||||||
|
2. **Authorization layer** (management API endpoints):
|
||||||
|
- EVERY endpoint verifies: token.sub == requested_resource_owner
|
||||||
|
- Example: GET /users/{user_id}/session checks token_user_id == path_user_id
|
||||||
|
- Users can ONLY access their own resources, never another user's
|
||||||
|
|
||||||
|
3. **Attack scenario analysis**:
|
||||||
|
- Attacker with stolen token for App A cannot access user B's data
|
||||||
|
- Token's `sub` claim is cryptographically bound to a specific user
|
||||||
|
- Authorization layer rejects cross-user access attempts (403 Forbidden)
|
||||||
|
|
||||||
|
4. **Why audience validation isn't needed here**:
|
||||||
|
- Audience validation prevents token confusion attacks across services
|
||||||
|
- But management API authorization already gates access per-user
|
||||||
|
- A token valid for "astrolabe" is still bound to user X, not user Y
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Bearer token to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccessToken if valid (regardless of audience), None otherwise
|
||||||
|
"""
|
||||||
|
# Check cache first (using separate cache key to avoid mixing with MCP tokens)
|
||||||
|
cache_key = f"mgmt:{hashlib.sha256(token.encode()).hexdigest()}"
|
||||||
|
if cache_key in self._token_cache:
|
||||||
|
userinfo, expiry = self._token_cache[cache_key]
|
||||||
|
if time.time() < expiry:
|
||||||
|
logger.debug("Management API token found in cache")
|
||||||
|
oauth_token_cache_hits_total.labels(hit="true").inc()
|
||||||
|
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||||
|
scope_string = userinfo.get("scope", "")
|
||||||
|
scopes = scope_string.split() if scope_string else []
|
||||||
|
return AccessToken(
|
||||||
|
token=token,
|
||||||
|
client_id=userinfo.get("client_id", ""),
|
||||||
|
scopes=scopes,
|
||||||
|
expires_at=int(expiry),
|
||||||
|
resource=username,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
del self._token_cache[cache_key]
|
||||||
|
|
||||||
|
oauth_token_cache_hits_total.labels(hit="false").inc()
|
||||||
|
|
||||||
|
# Verify token without audience check
|
||||||
|
return await self._verify_without_audience_check(token, cache_key)
|
||||||
|
|
||||||
async def _verify_mcp_audience(self, token: str) -> AccessToken | None:
|
async def _verify_mcp_audience(self, token: str) -> AccessToken | None:
|
||||||
"""
|
"""
|
||||||
Validate token has MCP audience.
|
Validate token has MCP audience.
|
||||||
@@ -186,6 +251,78 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
record_oauth_token_validation(validation_method, "error")
|
record_oauth_token_validation(validation_method, "error")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _verify_without_audience_check(
|
||||||
|
self, token: str, cache_key: str
|
||||||
|
) -> AccessToken | None:
|
||||||
|
"""
|
||||||
|
Verify token validity without checking MCP audience or issuer.
|
||||||
|
|
||||||
|
Used for management API where tokens from Astrolabe (NC PHP app) need to
|
||||||
|
be accepted. These tokens are issued by Nextcloud OIDC to Astrolabe's
|
||||||
|
OAuth client, not MCP server's client.
|
||||||
|
|
||||||
|
What we verify:
|
||||||
|
- ✓ Token signature (cryptographic proof token is from Nextcloud OIDC)
|
||||||
|
- ✓ Token expiration (not expired)
|
||||||
|
- ✓ Token structure (valid JWT format)
|
||||||
|
|
||||||
|
What we skip:
|
||||||
|
- ✗ Audience check (token may have Astrolabe's audience, not MCP's)
|
||||||
|
- ✗ Issuer check (token may have internal Nextcloud URL as issuer)
|
||||||
|
|
||||||
|
Security guarantee:
|
||||||
|
- Authorization is enforced by management API endpoints
|
||||||
|
- Each endpoint verifies: token.sub == requested_resource_owner
|
||||||
|
- See verify_token_for_management_api() docstring for full security model
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Bearer token to verify
|
||||||
|
cache_key: Cache key for storing validation result
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccessToken if valid, None otherwise
|
||||||
|
"""
|
||||||
|
validation_method = "unknown"
|
||||||
|
try:
|
||||||
|
# Attempt JWT verification first
|
||||||
|
# Skip issuer check for management API tokens (may have internal URL)
|
||||||
|
if self._is_jwt_format(token) and self.jwks_client:
|
||||||
|
validation_method = "jwt"
|
||||||
|
payload = await self._verify_jwt_signature(
|
||||||
|
token, skip_issuer_check=True
|
||||||
|
)
|
||||||
|
if payload:
|
||||||
|
record_oauth_token_validation("jwt", "valid")
|
||||||
|
else:
|
||||||
|
record_oauth_token_validation("jwt", "invalid")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# Fall back to introspection for opaque tokens
|
||||||
|
validation_method = "introspect"
|
||||||
|
payload = await self._introspect_token(token)
|
||||||
|
if payload:
|
||||||
|
record_oauth_token_validation("introspect", "valid")
|
||||||
|
else:
|
||||||
|
record_oauth_token_validation("introspect", "invalid")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check payload is valid
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Skip audience validation - any valid Nextcloud token is accepted
|
||||||
|
logger.debug(
|
||||||
|
f"Management API token validated (no audience check) for user: {payload.get('sub')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache and return the token
|
||||||
|
return self._create_access_token_with_cache_key(token, payload, cache_key)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Management API token verification failed: {e}")
|
||||||
|
record_oauth_token_validation(validation_method, "error")
|
||||||
|
return None
|
||||||
|
|
||||||
def _has_mcp_audience(self, payload: dict[str, Any]) -> bool:
|
def _has_mcp_audience(self, payload: dict[str, Any]) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if token has MCP audience.
|
Check if token has MCP audience.
|
||||||
@@ -230,12 +367,15 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
"""
|
"""
|
||||||
return "." in token and token.count(".") == 2
|
return "." in token and token.count(".") == 2
|
||||||
|
|
||||||
async def _verify_jwt_signature(self, token: str) -> dict[str, Any] | None:
|
async def _verify_jwt_signature(
|
||||||
|
self, token: str, skip_issuer_check: bool = False
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
"""
|
"""
|
||||||
Verify JWT token with signature validation using JWKS.
|
Verify JWT token with signature validation using JWKS.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: JWT token to verify
|
token: JWT token to verify
|
||||||
|
skip_issuer_check: If True, skip issuer validation (for management API tokens)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decoded payload if valid, None if invalid
|
Decoded payload if valid, None if invalid
|
||||||
@@ -248,25 +388,22 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
|
|
||||||
# Verify and decode JWT
|
# Verify and decode JWT
|
||||||
# Note: We don't validate audience here - that's done separately based on mode
|
# Note: We don't validate audience here - that's done separately based on mode
|
||||||
|
# Issuer validation can be skipped for management API tokens (from Astrolabe)
|
||||||
|
should_verify_issuer = (
|
||||||
|
not skip_issuer_check
|
||||||
|
and hasattr(self.settings, "oidc_issuer")
|
||||||
|
and self.settings.oidc_issuer
|
||||||
|
)
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
signing_key.key,
|
signing_key.key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
issuer=(
|
issuer=(self.settings.oidc_issuer if should_verify_issuer else None),
|
||||||
self.settings.oidc_issuer
|
|
||||||
if hasattr(self.settings, "oidc_issuer")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
options={
|
options={
|
||||||
"verify_signature": True,
|
"verify_signature": True,
|
||||||
"verify_exp": True,
|
"verify_exp": True,
|
||||||
"verify_iat": True,
|
"verify_iat": True,
|
||||||
"verify_iss": (
|
"verify_iss": should_verify_issuer,
|
||||||
True
|
|
||||||
if hasattr(self.settings, "oidc_issuer")
|
|
||||||
and self.settings.oidc_issuer
|
|
||||||
else False
|
|
||||||
),
|
|
||||||
"verify_aud": False, # We handle audience validation separately
|
"verify_aud": False, # We handle audience validation separately
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -358,6 +495,24 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
token: The bearer token
|
token: The bearer token
|
||||||
payload: Validated token payload
|
payload: Validated token payload
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccessToken object or None if required fields missing
|
||||||
|
"""
|
||||||
|
# Use default cache key (hash of token)
|
||||||
|
cache_key = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
return self._create_access_token_with_cache_key(token, payload, cache_key)
|
||||||
|
|
||||||
|
def _create_access_token_with_cache_key(
|
||||||
|
self, token: str, payload: dict[str, Any], cache_key: str
|
||||||
|
) -> AccessToken | None:
|
||||||
|
"""
|
||||||
|
Create AccessToken object from validated token payload with custom cache key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The bearer token
|
||||||
|
payload: Validated token payload
|
||||||
|
cache_key: Key to use for caching (allows separate caches for MCP vs management API)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AccessToken object or None if required fields missing
|
AccessToken object or None if required fields missing
|
||||||
"""
|
"""
|
||||||
@@ -382,14 +537,13 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
logger.warning("No 'exp' claim in token, using default TTL")
|
logger.warning("No 'exp' claim in token, using default TTL")
|
||||||
exp = int(time.time() + self.cache_ttl)
|
exp = int(time.time() + self.cache_ttl)
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result with the provided key
|
||||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
||||||
userinfo = {
|
userinfo = {
|
||||||
"sub": username,
|
"sub": username,
|
||||||
"scope": scope_string,
|
"scope": scope_string,
|
||||||
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
||||||
}
|
}
|
||||||
self._token_cache[token_hash] = (userinfo, exp)
|
self._token_cache[cache_key] = (userinfo, exp)
|
||||||
|
|
||||||
return AccessToken(
|
return AccessToken(
|
||||||
token=token,
|
token=token,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ For OAuth mode: Requires browser-based OAuth login to establish session.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -385,8 +386,6 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
|
|||||||
return user_context
|
return user_context
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
|
||||||
|
|
||||||
logger.error(f"Error retrieving user info: {e}")
|
logger.error(f"Error retrieving user info: {e}")
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import anyio
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from starlette.authentication import requires
|
from starlette.authentication import requires
|
||||||
@@ -396,8 +397,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
coords = pca.fit_transform(vectors)
|
coords = pca.fit_transform(vectors)
|
||||||
return coords, pca
|
return coords, pca
|
||||||
|
|
||||||
import anyio
|
|
||||||
|
|
||||||
with trace_operation(
|
with trace_operation(
|
||||||
"vector_viz.pca_compute",
|
"vector_viz.pca_compute",
|
||||||
attributes={
|
attributes={
|
||||||
|
|||||||
@@ -285,28 +285,23 @@ class DeckClient(BaseNextcloudClient):
|
|||||||
archived: Optional[bool] = None,
|
archived: Optional[bool] = None,
|
||||||
done: Optional[str] = None,
|
done: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
# First, get the current card to use existing values for required fields
|
# Deck PUT API is a full replacement - all required fields must be sent.
|
||||||
|
# Fetch current card to preserve values for fields not being updated.
|
||||||
current_card = await self.get_card(board_id, stack_id, card_id)
|
current_card = await self.get_card(board_id, stack_id, card_id)
|
||||||
|
|
||||||
json_data = {}
|
# Build payload with required fields always included
|
||||||
if title is not None:
|
json_data = {
|
||||||
json_data["title"] = title
|
# Title is required by the API
|
||||||
if description is not None:
|
"title": title if title is not None else current_card.title,
|
||||||
json_data["description"] = description
|
# Type is required by the API
|
||||||
# Type is required by the API, use provided or keep current
|
"type": type if type is not None else current_card.type,
|
||||||
json_data["type"] = type if type is not None else current_card.type
|
# Owner is required by the API (model validator ensures it's a string)
|
||||||
# Owner is required by the API, use provided or keep current
|
"owner": owner if owner is not None else current_card.owner,
|
||||||
json_data["owner"] = (
|
# Description must be sent to preserve it (PUT clears omitted fields)
|
||||||
owner
|
"description": description
|
||||||
if owner is not None
|
if description is not None
|
||||||
else (
|
else (current_card.description or ""),
|
||||||
current_card.owner
|
}
|
||||||
if isinstance(current_card.owner, str)
|
|
||||||
else current_card.owner.uid
|
|
||||||
if hasattr(current_card.owner, "uid")
|
|
||||||
else current_card.owner.primaryKey
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if order is not None:
|
if order is not None:
|
||||||
json_data["order"] = order
|
json_data["order"] = order
|
||||||
if duedate is not None:
|
if duedate is not None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import logging.config
|
import logging.config
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -163,6 +164,12 @@ def get_document_processor_config() -> dict[str, Any]:
|
|||||||
class Settings:
|
class Settings:
|
||||||
"""Application settings from environment variables."""
|
"""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
|
# OAuth/OIDC settings
|
||||||
oidc_discovery_url: Optional[str] = None
|
oidc_discovery_url: Optional[str] = None
|
||||||
oidc_client_id: Optional[str] = None
|
oidc_client_id: Optional[str] = None
|
||||||
@@ -331,7 +338,6 @@ class Settings:
|
|||||||
Returns:
|
Returns:
|
||||||
Collection name string
|
Collection name string
|
||||||
"""
|
"""
|
||||||
import socket
|
|
||||||
|
|
||||||
# Use explicit override if user configured non-default value
|
# Use explicit override if user configured non-default value
|
||||||
if self.qdrant_collection != "nextcloud_content":
|
if self.qdrant_collection != "nextcloud_content":
|
||||||
@@ -350,6 +356,131 @@ class Settings:
|
|||||||
|
|
||||||
return f"{deployment_id}-{model_name}"
|
return f"{deployment_id}-{model_name}"
|
||||||
|
|
||||||
|
# ADR-021: Property aliases for new naming convention
|
||||||
|
# These provide the new names while maintaining backward compatibility with old field names
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enable_semantic_search(self) -> bool:
|
||||||
|
"""Semantic search enabled (ADR-021 alias for vector_sync_enabled)."""
|
||||||
|
return self.vector_sync_enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enable_background_operations(self) -> bool:
|
||||||
|
"""Background operations enabled (ADR-021 alias for enable_offline_access)."""
|
||||||
|
return self.enable_offline_access
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def get_settings() -> Settings:
|
||||||
"""Get application settings from environment variables.
|
"""Get application settings from environment variables.
|
||||||
@@ -357,7 +488,13 @@ def get_settings() -> Settings:
|
|||||||
Returns:
|
Returns:
|
||||||
Settings object with configuration values
|
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(
|
return Settings(
|
||||||
|
# Deployment mode (ADR-021)
|
||||||
|
deployment_mode=os.getenv("MCP_DEPLOYMENT_MODE"),
|
||||||
# OAuth/OIDC settings
|
# OAuth/OIDC settings
|
||||||
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
|
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
|
||||||
oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"),
|
oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"),
|
||||||
@@ -378,9 +515,7 @@ def get_settings() -> Settings:
|
|||||||
enable_token_exchange=(
|
enable_token_exchange=(
|
||||||
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
||||||
),
|
),
|
||||||
enable_offline_access=(
|
enable_offline_access=enable_background_operations, # Smart dependency resolution
|
||||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
|
||||||
),
|
|
||||||
# Multi-user BasicAuth pass-through mode
|
# Multi-user BasicAuth pass-through mode
|
||||||
enable_multi_user_basic_auth=(
|
enable_multi_user_basic_auth=(
|
||||||
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
|
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
|
||||||
@@ -391,9 +526,7 @@ def get_settings() -> Settings:
|
|||||||
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
||||||
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
||||||
# Vector sync settings (ADR-007)
|
# Vector sync settings (ADR-007)
|
||||||
vector_sync_enabled=(
|
vector_sync_enabled=enable_semantic_search, # Smart dependency resolution
|
||||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
|
||||||
),
|
|
||||||
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")),
|
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")),
|
||||||
vector_sync_processor_workers=int(
|
vector_sync_processor_workers=int(
|
||||||
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3")
|
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ See ADR-020 for detailed architecture and deployment mode documentation.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
@@ -105,15 +106,13 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
|||||||
],
|
],
|
||||||
conditional={
|
conditional={
|
||||||
"enable_offline_access": [
|
"enable_offline_access": [
|
||||||
"oidc_client_id",
|
# OAuth credentials validated separately (lines 397-406) with clearer error message
|
||||||
"oidc_client_secret",
|
|
||||||
"token_encryption_key",
|
"token_encryption_key",
|
||||||
"token_storage_db",
|
"token_storage_db",
|
||||||
],
|
],
|
||||||
"vector_sync_enabled": [
|
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
|
||||||
# Requires offline access for background sync
|
# enables background operations in multi-user modes. No explicit
|
||||||
"enable_offline_access",
|
# enable_offline_access setting required.
|
||||||
],
|
|
||||||
},
|
},
|
||||||
description="Multi-user deployment with BasicAuth pass-through. "
|
description="Multi-user deployment with BasicAuth pass-through. "
|
||||||
"Users provide credentials in request headers. "
|
"Users provide credentials in request headers. "
|
||||||
@@ -152,9 +151,9 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
|||||||
"token_encryption_key",
|
"token_encryption_key",
|
||||||
"token_storage_db",
|
"token_storage_db",
|
||||||
],
|
],
|
||||||
"vector_sync_enabled": [
|
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
|
||||||
"enable_offline_access", # Background sync requires refresh tokens
|
# enables background operations in multi-user modes. No explicit
|
||||||
],
|
# enable_offline_access setting required.
|
||||||
},
|
},
|
||||||
description="OAuth multi-user deployment with single-audience tokens. "
|
description="OAuth multi-user deployment with single-audience tokens. "
|
||||||
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
|
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
|
||||||
@@ -192,9 +191,9 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
|||||||
"token_encryption_key",
|
"token_encryption_key",
|
||||||
"token_storage_db",
|
"token_storage_db",
|
||||||
],
|
],
|
||||||
"vector_sync_enabled": [
|
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
|
||||||
"enable_offline_access",
|
# enables background operations in multi-user modes. No explicit
|
||||||
],
|
# enable_offline_access setting required.
|
||||||
},
|
},
|
||||||
description="OAuth multi-user deployment with token exchange (RFC 8693). "
|
description="OAuth multi-user deployment with token exchange (RFC 8693). "
|
||||||
"MCP tokens are separate from Nextcloud tokens. "
|
"MCP tokens are separate from Nextcloud tokens. "
|
||||||
@@ -225,7 +224,8 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
|||||||
def detect_auth_mode(settings: Settings) -> AuthMode:
|
def detect_auth_mode(settings: Settings) -> AuthMode:
|
||||||
"""Detect authentication mode from configuration.
|
"""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)
|
1. Smithery (explicit flag)
|
||||||
2. Token exchange (most specific OAuth mode)
|
2. Token exchange (most specific OAuth mode)
|
||||||
3. Multi-user BasicAuth
|
3. Multi-user BasicAuth
|
||||||
@@ -237,12 +237,41 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Detected AuthMode
|
Detected AuthMode
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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)
|
# Check for Smithery mode (explicit environment variable)
|
||||||
# Note: This checks the environment directly, not settings
|
# Note: This checks the environment directly, not settings
|
||||||
# because Smithery mode has no settings-based config
|
# because Smithery mode has no settings-based config
|
||||||
import os
|
|
||||||
|
|
||||||
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
||||||
return AuthMode.SMITHERY_STATELESS
|
return AuthMode.SMITHERY_STATELESS
|
||||||
|
|
||||||
@@ -364,22 +393,20 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if mode == AuthMode.MULTI_USER_BASIC:
|
if mode == AuthMode.MULTI_USER_BASIC:
|
||||||
# Validate that if offline access enabled, we have OAuth credentials
|
# If background operations enabled, check for OAuth credentials (for app password retrieval)
|
||||||
|
# Allow DCR as fallback, just like OAuth modes
|
||||||
if settings.enable_offline_access:
|
if settings.enable_offline_access:
|
||||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||||
errors.append(
|
logger.info(
|
||||||
f"[{mode.value}] NEXTCLOUD_OIDC_CLIENT_ID and "
|
f"[{mode.value}] OAuth credentials not configured. "
|
||||||
"NEXTCLOUD_OIDC_CLIENT_SECRET are required when "
|
"Will attempt Dynamic Client Registration (DCR) at startup "
|
||||||
"ENABLE_OFFLINE_ACCESS is enabled (for app password retrieval)"
|
"(required for app password retrieval via Astrolabe)."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate vector sync requirements
|
# Note: Vector sync no longer requires explicit ENABLE_OFFLINE_ACCESS setting
|
||||||
if settings.vector_sync_enabled and not settings.enable_offline_access:
|
# ENABLE_SEMANTIC_SEARCH (formerly VECTOR_SYNC_ENABLED) automatically enables
|
||||||
errors.append(
|
# background operations in multi-user modes via smart dependency resolution
|
||||||
f"[{mode.value}] ENABLE_OFFLINE_ACCESS must be enabled when "
|
# in config.py
|
||||||
"VECTOR_SYNC_ENABLED is true (background sync requires "
|
|
||||||
"app passwords or refresh tokens)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Note: Embedding provider validation removed - Simple provider is always
|
# Note: Embedding provider validation removed - Simple provider is always
|
||||||
# available as fallback (ADR-015). Users can optionally configure Ollama or OpenAI
|
# available as fallback (ADR-015). Users can optionally configure Ollama or OpenAI
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import tempfile
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
# NOTE: Do NOT call pymupdf.layout.activate() here!
|
# NOTE: Do NOT call pymupdf.layout.activate() here!
|
||||||
# It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True,
|
# It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True,
|
||||||
# causing it to return a string instead of a list[dict].
|
# causing it to return a string instead of a list[dict].
|
||||||
@@ -95,7 +97,6 @@ class PyMuPDFProcessor(DocumentProcessor):
|
|||||||
Raises:
|
Raises:
|
||||||
ProcessorError: If PDF processing fails
|
ProcessorError: If PDF processing fails
|
||||||
"""
|
"""
|
||||||
import anyio
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import anyio
|
||||||
from fastembed import SparseTextEmbedding
|
from fastembed import SparseTextEmbedding
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -67,7 +68,6 @@ class BM25SparseEmbeddingProvider:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with 'indices' and 'values' keys for Qdrant sparse vector
|
Dictionary with 'indices' and 'values' keys for Qdrant sparse vector
|
||||||
"""
|
"""
|
||||||
import anyio
|
|
||||||
|
|
||||||
# Run CPU-bound BM25 encoding in thread pool
|
# Run CPU-bound BM25 encoding in thread pool
|
||||||
return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined]
|
return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined]
|
||||||
@@ -82,7 +82,6 @@ class BM25SparseEmbeddingProvider:
|
|||||||
Returns:
|
Returns:
|
||||||
List of dictionaries with 'indices' and 'values' for each text
|
List of dictionaries with 'indices' and 'values' for each text
|
||||||
"""
|
"""
|
||||||
import anyio
|
|
||||||
|
|
||||||
# Run CPU-bound BM25 encoding in thread pool to avoid blocking event loop
|
# Run CPU-bound BM25 encoding in thread pool to avoid blocking event loop
|
||||||
sparse_embeddings = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
sparse_embeddings = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ provides CLI integration.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
@@ -98,7 +99,6 @@ def get_current_revision(database_path: str | Path | None = None) -> str | None:
|
|||||||
Returns:
|
Returns:
|
||||||
Current revision ID or None if not versioned
|
Current revision ID or None if not versioned
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
if database_path is None:
|
if database_path is None:
|
||||||
database_path = "/app/data/tokens.db"
|
database_path = "/app/data/tokens.db"
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ and resource usage. Metrics are organized by category:
|
|||||||
- External Dependency Health Metrics
|
- External Dependency Health Metrics
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from prometheus_client import (
|
from prometheus_client import (
|
||||||
Counter,
|
Counter,
|
||||||
@@ -423,8 +425,6 @@ def instrument_tool(func):
|
|||||||
Returns:
|
Returns:
|
||||||
Wrapped function with metrics and tracing instrumentation
|
Wrapped function with metrics and tracing instrumentation
|
||||||
"""
|
"""
|
||||||
import functools
|
|
||||||
import time
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
"""Base interfaces and data structures for search algorithms."""
|
"""Base interfaces and data structures for search algorithms."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Protocol, runtime_checkable
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class NextcloudClientProtocol(Protocol):
|
class NextcloudClientProtocol(Protocol):
|
||||||
@@ -78,13 +85,6 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
|
|||||||
>>> if "note" in types:
|
>>> if "note" in types:
|
||||||
... # Search notes
|
... # Search notes
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
|
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ position markers for better visualization and understanding of search results.
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pymupdf
|
||||||
|
import pymupdf4llm
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -549,8 +552,6 @@ async def _fetch_document_text(
|
|||||||
# Extract text from PDF using PyMuPDF
|
# Extract text from PDF using PyMuPDF
|
||||||
# IMPORTANT: Use pymupdf4llm.to_markdown() to match indexing extraction
|
# IMPORTANT: Use pymupdf4llm.to_markdown() to match indexing extraction
|
||||||
# This ensures character offsets align between indexed chunks and retrieval
|
# This ensures character offsets align between indexed chunks and retrieval
|
||||||
import pymupdf
|
|
||||||
import pymupdf4llm
|
|
||||||
|
|
||||||
logger.debug(f"Extracting text from PDF: {file_path}")
|
logger.debug(f"Extracting text from PDF: {file_path}")
|
||||||
pdf_doc = pymupdf.open(stream=file_content, filetype="pdf")
|
pdf_doc = pymupdf.open(stream=file_content, filetype="pdf")
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ varies between indexing and rendering.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
@@ -77,8 +80,6 @@ class PDFHighlighter:
|
|||||||
Tuple of (full_text, page_boundaries) where page_boundaries is a list of:
|
Tuple of (full_text, page_boundaries) where page_boundaries is a list of:
|
||||||
{"page": 1, "start_offset": 0, "end_offset": 1234}
|
{"page": 1, "start_offset": 0, "end_offset": 1234}
|
||||||
"""
|
"""
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
page_boundaries = []
|
page_boundaries = []
|
||||||
text_parts = []
|
text_parts = []
|
||||||
@@ -110,7 +111,6 @@ class PDFHighlighter:
|
|||||||
full_text = "".join(text_parts)
|
full_text = "".join(text_parts)
|
||||||
|
|
||||||
# Clean up temp directory and extracted images
|
# Clean up temp directory and extracted images
|
||||||
import shutil
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(temp_dir)
|
shutil.rmtree(temp_dir)
|
||||||
@@ -590,8 +590,6 @@ class PDFHighlighter:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (png_bytes, page_number, highlight_count) or None if failed
|
Tuple of (png_bytes, page_number, highlight_count) or None if failed
|
||||||
"""
|
"""
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
temp_pdf_path = None
|
temp_pdf_path = None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Optional
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import jwt
|
||||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
from mcp.server.auth.provider import AccessToken
|
from mcp.server.auth.provider import AccessToken
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
@@ -53,8 +54,6 @@ async def extract_user_id_from_token(ctx: Context) -> str:
|
|||||||
# Try JWT decode first
|
# Try JWT decode first
|
||||||
if is_jwt:
|
if is_jwt:
|
||||||
try:
|
try:
|
||||||
import jwt
|
|
||||||
|
|
||||||
payload = jwt.decode(token, options={"verify_signature": False})
|
payload = jwt.decode(token, options={"verify_signature": False})
|
||||||
user_id = payload.get("sub", "unknown")
|
user_id = payload.get("sub", "unknown")
|
||||||
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
||||||
@@ -303,16 +302,17 @@ async def provision_nextcloud_access(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get configuration
|
# Get configuration using settings (handles both ENABLE_BACKGROUND_OPERATIONS
|
||||||
enable_offline_access = (
|
# and ENABLE_OFFLINE_ACCESS environment variables)
|
||||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
from nextcloud_mcp_server.config import get_settings
|
||||||
)
|
|
||||||
if not enable_offline_access:
|
settings = get_settings()
|
||||||
|
if not settings.enable_offline_access:
|
||||||
return ProvisioningResult(
|
return ProvisioningResult(
|
||||||
success=False,
|
success=False,
|
||||||
message=(
|
message=(
|
||||||
"Offline access is not enabled. "
|
"Offline access is not enabled. "
|
||||||
"Set ENABLE_OFFLINE_ACCESS=true to use this feature."
|
"Set ENABLE_BACKGROUND_OPERATIONS=true to use this feature."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -488,13 +488,14 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
|||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
# Not logged in - generate OAuth URL for Flow 2
|
# Not logged in - generate OAuth URL for Flow 2
|
||||||
enable_offline_access = (
|
# Use settings (handles both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS)
|
||||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
from nextcloud_mcp_server.config import get_settings
|
||||||
)
|
|
||||||
if not enable_offline_access:
|
settings = get_settings()
|
||||||
|
if not settings.enable_offline_access:
|
||||||
return (
|
return (
|
||||||
"Not logged in. Offline access is not enabled. "
|
"Not logged in. Offline access is not enabled. "
|
||||||
"Set ENABLE_OFFLINE_ACCESS=true to use this feature."
|
"Set ENABLE_BACKGROUND_OPERATIONS=true to use this feature."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get MCP server's OAuth client credentials
|
# Get MCP server's OAuth client credentials
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Semantic search MCP tools using vector database."""
|
"""Semantic search MCP tools using vector database."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from httpx import RequestError
|
from httpx import RequestError
|
||||||
@@ -656,7 +657,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
This is useful for determining when vector indexing is complete
|
This is useful for determining when vector indexing is complete
|
||||||
after creating or updating content across all indexed apps.
|
after creating or updating content across all indexed apps.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
|
|
||||||
# Check if vector sync is enabled
|
# Check if vector sync is enabled
|
||||||
vector_sync_enabled = (
|
vector_sync_enabled = (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
@@ -120,7 +121,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# For binary files, return metadata and base64 encoded content
|
# For binary files, return metadata and base64 encoded content
|
||||||
import base64
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"path": path,
|
"path": path,
|
||||||
@@ -156,8 +156,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Handle base64 encoded content
|
# Handle base64 encoded content
|
||||||
if content_type and "base64" in content_type.lower():
|
if content_type and "base64" in content_type.lower():
|
||||||
import base64
|
|
||||||
|
|
||||||
content_bytes = base64.b64decode(content)
|
content_bytes = base64.b64decode(content)
|
||||||
content_type = content_type.replace(";base64", "")
|
content_type = content_type.replace(";base64", "")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import anyio
|
||||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -68,7 +69,6 @@ class DocumentChunker:
|
|||||||
Returns:
|
Returns:
|
||||||
List of chunks with their character positions in the original content
|
List of chunks with their character positions in the original content
|
||||||
"""
|
"""
|
||||||
import anyio
|
|
||||||
|
|
||||||
# Handle empty content - return single empty chunk for backward compatibility
|
# Handle empty content - return single empty chunk for backward compatibility
|
||||||
if not content:
|
if not content:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""HTML to Markdown conversion utilities for vector sync."""
|
"""HTML to Markdown conversion utilities for vector sync."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from markdownify import markdownify as md
|
from markdownify import markdownify as md
|
||||||
|
|
||||||
@@ -43,7 +44,6 @@ def html_to_markdown(html_content: str | None) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to convert HTML to Markdown: {e}")
|
logger.warning(f"Failed to convert HTML to Markdown: {e}")
|
||||||
# Fallback: strip all HTML tags as a last resort
|
# Fallback: strip all HTML tags as a last resort
|
||||||
import re
|
|
||||||
|
|
||||||
text = re.sub(r"<[^>]+>", " ", html_content)
|
text = re.sub(r"<[^>]+>", " ", html_content)
|
||||||
return " ".join(text.split()) # Normalize whitespace
|
return " ".join(text.split()) # Normalize whitespace
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
"""OAuth mode vector sync orchestration.
|
"""Multi-user vector sync orchestration.
|
||||||
|
|
||||||
Manages multi-user background vector sync when running in OAuth mode
|
Manages background vector sync for multi-user deployments:
|
||||||
with ENABLE_OFFLINE_ACCESS=true:
|
- User Manager: Monitors storage for user changes
|
||||||
- User Manager: Monitors RefreshTokenStorage for user changes
|
|
||||||
- Per-User Scanners: One scanner task per provisioned user
|
- Per-User Scanners: One scanner task per provisioned user
|
||||||
- Shared Processor Pool: Processes documents from all users
|
- Shared Processor Pool: Processes documents from all users
|
||||||
|
|
||||||
Supports dual credential types for background sync:
|
Authentication strategies are mutually exclusive by deployment mode:
|
||||||
- App passwords (interim solution, works today)
|
|
||||||
- OAuth refresh tokens (future, when Nextcloud supports OAuth for app APIs)
|
Multi-user BasicAuth mode (ENABLE_MULTI_USER_BASIC_AUTH=true):
|
||||||
|
- Uses app passwords obtained via Astrolabe Management API
|
||||||
|
- Users provision via Astrolabe personal settings
|
||||||
|
- OAuth is NOT used
|
||||||
|
|
||||||
|
OAuth mode (with external IdP like Keycloak):
|
||||||
|
- Uses OAuth refresh tokens via TokenBrokerService
|
||||||
|
- Users provision via browser OAuth flow
|
||||||
|
- App passwords are NOT used
|
||||||
|
|
||||||
|
These are separate concerns - no fallback between them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -59,16 +68,64 @@ class UserSyncState:
|
|||||||
started_at: float = field(default_factory=time.time)
|
started_at: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_client(
|
async def get_user_client_basic_auth(
|
||||||
|
user_id: str,
|
||||||
|
nextcloud_host: str,
|
||||||
|
) -> NextcloudClient:
|
||||||
|
"""Get an authenticated NextcloudClient using app password (BasicAuth mode).
|
||||||
|
|
||||||
|
For multi-user BasicAuth deployments where users provision app passwords
|
||||||
|
via Astrolabe personal settings. OAuth is NOT used in this mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authenticated NextcloudClient with BasicAuth
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotProvisionedError: If user has not provisioned an app password
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||||
|
raise NotProvisionedError(
|
||||||
|
"Astrolabe client credentials not configured. "
|
||||||
|
"Set OIDC_CLIENT_ID and OIDC_CLIENT_SECRET for app password retrieval."
|
||||||
|
)
|
||||||
|
|
||||||
|
astrolabe = AstrolabeClient(
|
||||||
|
nextcloud_host=nextcloud_host,
|
||||||
|
client_id=settings.oidc_client_id,
|
||||||
|
client_secret=settings.oidc_client_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
app_password = await astrolabe.get_user_app_password(user_id)
|
||||||
|
|
||||||
|
if not app_password:
|
||||||
|
raise NotProvisionedError(
|
||||||
|
f"User {user_id} has not provisioned an app password. "
|
||||||
|
f"User must configure background sync in Astrolabe personal settings."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Using app password for background sync: {user_id}")
|
||||||
|
return NextcloudClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
username=user_id,
|
||||||
|
auth=BasicAuth(user_id, app_password),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_client_oauth(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
token_broker: "TokenBrokerService",
|
token_broker: "TokenBrokerService",
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
) -> NextcloudClient:
|
) -> NextcloudClient:
|
||||||
"""Get an authenticated NextcloudClient for a user.
|
"""Get an authenticated NextcloudClient using OAuth refresh token.
|
||||||
|
|
||||||
Supports dual credential types with priority:
|
For OAuth deployments with external IdP where users provision via
|
||||||
1. App password from Astrolabe (works today with BasicAuth)
|
browser OAuth flow. App passwords are NOT used in this mode.
|
||||||
2. OAuth refresh token from storage (for future when OAuth fully supported)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User identifier
|
user_id: User identifier
|
||||||
@@ -76,45 +133,19 @@ async def get_user_client(
|
|||||||
nextcloud_host: Nextcloud base URL
|
nextcloud_host: Nextcloud base URL
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Authenticated NextcloudClient
|
Authenticated NextcloudClient with Bearer token
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotProvisionedError: If user has not provisioned offline access
|
NotProvisionedError: If user has not provisioned offline access
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
# Try app password first (interim solution, works today)
|
|
||||||
if settings.oidc_client_id and settings.oidc_client_secret:
|
|
||||||
try:
|
|
||||||
astrolabe = AstrolabeClient(
|
|
||||||
nextcloud_host=nextcloud_host,
|
|
||||||
client_id=settings.oidc_client_id,
|
|
||||||
client_secret=settings.oidc_client_secret,
|
|
||||||
)
|
|
||||||
app_password = await astrolabe.get_user_app_password(user_id)
|
|
||||||
|
|
||||||
if app_password:
|
|
||||||
logger.info(
|
|
||||||
f"Using app password for background sync: {user_id} "
|
|
||||||
f"(credential_type=app_password)"
|
|
||||||
)
|
|
||||||
return NextcloudClient(
|
|
||||||
base_url=nextcloud_host,
|
|
||||||
username=user_id,
|
|
||||||
auth=BasicAuth(user_id, app_password),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"App password not available for {user_id}: {e}")
|
|
||||||
|
|
||||||
# Fall back to OAuth refresh token
|
|
||||||
logger.info(
|
|
||||||
f"Using OAuth refresh token for background sync: {user_id} "
|
|
||||||
f"(credential_type=refresh_token)"
|
|
||||||
)
|
|
||||||
token = await token_broker.get_background_token(user_id, VECTOR_SYNC_SCOPES)
|
token = await token_broker.get_background_token(user_id, VECTOR_SYNC_SCOPES)
|
||||||
if not token:
|
if not token:
|
||||||
raise NotProvisionedError(f"User {user_id} has not provisioned offline access")
|
raise NotProvisionedError(
|
||||||
|
f"User {user_id} has not provisioned offline access. "
|
||||||
|
f"User must complete the OAuth provisioning flow."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Using OAuth refresh token for background sync: {user_id}")
|
||||||
return NextcloudClient.from_token(
|
return NextcloudClient.from_token(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
token=token,
|
token=token,
|
||||||
@@ -122,30 +153,66 @@ async def get_user_client(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_client(
|
||||||
|
user_id: str,
|
||||||
|
token_broker: "TokenBrokerService | None",
|
||||||
|
nextcloud_host: str,
|
||||||
|
*,
|
||||||
|
use_basic_auth: bool = False,
|
||||||
|
) -> NextcloudClient:
|
||||||
|
"""Get an authenticated NextcloudClient for a user.
|
||||||
|
|
||||||
|
Dispatches to the appropriate authentication strategy based on mode.
|
||||||
|
These are mutually exclusive - no fallback between them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
token_broker: Token broker for OAuth mode (can be None for BasicAuth mode)
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
use_basic_auth: If True, use app passwords via Astrolabe (BasicAuth mode).
|
||||||
|
If False, use OAuth refresh tokens (OAuth mode).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authenticated NextcloudClient
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotProvisionedError: If user has not provisioned access for the mode
|
||||||
|
"""
|
||||||
|
if use_basic_auth:
|
||||||
|
return await get_user_client_basic_auth(user_id, nextcloud_host)
|
||||||
|
else:
|
||||||
|
if token_broker is None:
|
||||||
|
raise ValueError("token_broker required for OAuth mode")
|
||||||
|
return await get_user_client_oauth(user_id, token_broker, nextcloud_host)
|
||||||
|
|
||||||
|
|
||||||
async def user_scanner_task(
|
async def user_scanner_task(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
send_stream: MemoryObjectSendStream[DocumentTask],
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
shutdown_event: anyio.Event,
|
shutdown_event: anyio.Event,
|
||||||
wake_event: anyio.Event,
|
wake_event: anyio.Event,
|
||||||
token_broker: "TokenBrokerService",
|
token_broker: "TokenBrokerService | None",
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
*,
|
*,
|
||||||
|
use_basic_auth: bool = False,
|
||||||
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Scanner task for a single user in OAuth mode.
|
"""Scanner task for a single user.
|
||||||
|
|
||||||
Gets a fresh token at the start of each scan cycle.
|
Gets fresh credentials at the start of each scan cycle.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User to scan
|
user_id: User to scan
|
||||||
send_stream: Stream to send changed documents to processors
|
send_stream: Stream to send changed documents to processors
|
||||||
shutdown_event: Event signaling shutdown
|
shutdown_event: Event signaling shutdown
|
||||||
wake_event: Event to trigger immediate scan
|
wake_event: Event to trigger immediate scan
|
||||||
token_broker: Token broker for obtaining access tokens
|
token_broker: Token broker for OAuth mode (None for BasicAuth mode)
|
||||||
nextcloud_host: Nextcloud base URL
|
nextcloud_host: Nextcloud base URL
|
||||||
|
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
||||||
task_status: Status object for signaling task readiness
|
task_status: Status object for signaling task readiness
|
||||||
"""
|
"""
|
||||||
logger.info(f"[OAuth] Scanner started for user: {user_id}")
|
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||||
|
logger.info(f"[{mode_label}] Scanner started for user: {user_id}")
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
task_status.started()
|
task_status.started()
|
||||||
@@ -153,8 +220,10 @@ async def user_scanner_task(
|
|||||||
while not shutdown_event.is_set():
|
while not shutdown_event.is_set():
|
||||||
nc_client = None
|
nc_client = None
|
||||||
try:
|
try:
|
||||||
# Get fresh token for this scan cycle
|
# Get fresh credentials for this scan cycle
|
||||||
nc_client = await get_user_client(user_id, token_broker, nextcloud_host)
|
nc_client = await get_user_client(
|
||||||
|
user_id, token_broker, nextcloud_host, use_basic_auth=use_basic_auth
|
||||||
|
)
|
||||||
|
|
||||||
# Scan user's documents
|
# Scan user's documents
|
||||||
await scan_user_documents(
|
await scan_user_documents(
|
||||||
@@ -165,12 +234,14 @@ async def user_scanner_task(
|
|||||||
|
|
||||||
except NotProvisionedError:
|
except NotProvisionedError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[OAuth] User {user_id} no longer provisioned, stopping scanner"
|
f"[{mode_label}] User {user_id} no longer provisioned, stopping scanner"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[OAuth] Scanner error for {user_id}: {e}", exc_info=True)
|
logger.error(
|
||||||
|
f"[{mode_label}] Scanner error for {user_id}: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if nc_client:
|
if nc_client:
|
||||||
@@ -183,33 +254,36 @@ async def user_scanner_task(
|
|||||||
except anyio.get_cancelled_exc_class():
|
except anyio.get_cancelled_exc_class():
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.info(f"[OAuth] Scanner stopped for user: {user_id}")
|
logger.info(f"[{mode_label}] Scanner stopped for user: {user_id}")
|
||||||
|
|
||||||
|
|
||||||
async def oauth_processor_task(
|
async def multi_user_processor_task(
|
||||||
worker_id: int,
|
worker_id: int,
|
||||||
receive_stream: MemoryObjectReceiveStream[DocumentTask],
|
receive_stream: MemoryObjectReceiveStream[DocumentTask],
|
||||||
shutdown_event: anyio.Event,
|
shutdown_event: anyio.Event,
|
||||||
token_broker: "TokenBrokerService",
|
token_broker: "TokenBrokerService | None",
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
|
use_basic_auth: bool = False,
|
||||||
*,
|
*,
|
||||||
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Processor task for OAuth mode.
|
"""Processor task for multi-user mode.
|
||||||
|
|
||||||
Handles documents from any user by fetching tokens on-demand.
|
Handles documents from any user by fetching credentials on-demand.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
worker_id: Worker identifier for logging
|
worker_id: Worker identifier for logging
|
||||||
receive_stream: Stream to receive documents from
|
receive_stream: Stream to receive documents from
|
||||||
shutdown_event: Event signaling shutdown
|
shutdown_event: Event signaling shutdown
|
||||||
token_broker: Token broker for obtaining access tokens
|
token_broker: Token broker for OAuth mode (None for BasicAuth mode)
|
||||||
nextcloud_host: Nextcloud base URL
|
nextcloud_host: Nextcloud base URL
|
||||||
|
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
||||||
task_status: Status object for signaling task readiness
|
task_status: Status object for signaling task readiness
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.vector.processor import process_document
|
from nextcloud_mcp_server.vector.processor import process_document
|
||||||
|
|
||||||
logger.info(f"[OAuth] Processor {worker_id} started")
|
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||||
|
logger.info(f"[{mode_label}] Processor {worker_id} started")
|
||||||
task_status.started()
|
task_status.started()
|
||||||
|
|
||||||
while not shutdown_event.is_set():
|
while not shutdown_event.is_set():
|
||||||
@@ -220,9 +294,12 @@ async def oauth_processor_task(
|
|||||||
with anyio.fail_after(1.0):
|
with anyio.fail_after(1.0):
|
||||||
doc_task = await receive_stream.receive()
|
doc_task = await receive_stream.receive()
|
||||||
|
|
||||||
# Get token for THIS document's user
|
# Get credentials for THIS document's user
|
||||||
nc_client = await get_user_client(
|
nc_client = await get_user_client(
|
||||||
doc_task.user_id, token_broker, nextcloud_host
|
doc_task.user_id,
|
||||||
|
token_broker,
|
||||||
|
nextcloud_host,
|
||||||
|
use_basic_auth=use_basic_auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process the document
|
# Process the document
|
||||||
@@ -232,13 +309,13 @@ async def oauth_processor_task(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
except anyio.EndOfStream:
|
except anyio.EndOfStream:
|
||||||
logger.info(f"[OAuth] Processor {worker_id}: Stream closed, exiting")
|
logger.info(f"[{mode_label}] Processor {worker_id}: Stream closed, exiting")
|
||||||
break
|
break
|
||||||
|
|
||||||
except NotProvisionedError:
|
except NotProvisionedError:
|
||||||
if doc_task:
|
if doc_task:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[OAuth] User {doc_task.user_id} not provisioned, "
|
f"[{mode_label}] User {doc_task.user_id} not provisioned, "
|
||||||
f"skipping {doc_task.doc_type}_{doc_task.doc_id}"
|
f"skipping {doc_task.doc_type}_{doc_task.doc_id}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -246,18 +323,24 @@ async def oauth_processor_task(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if doc_task:
|
if doc_task:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[OAuth] Processor {worker_id} error processing "
|
f"[{mode_label}] Processor {worker_id} error processing "
|
||||||
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}",
|
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"[OAuth] Processor {worker_id} error: {e}", exc_info=True)
|
logger.error(
|
||||||
|
f"[{mode_label}] Processor {worker_id} error: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if nc_client:
|
if nc_client:
|
||||||
await nc_client.close()
|
await nc_client.close()
|
||||||
|
|
||||||
logger.info(f"[OAuth] Processor {worker_id} stopped")
|
logger.info(f"[{mode_label}] Processor {worker_id} stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# Backward compatibility alias
|
||||||
|
oauth_processor_task = multi_user_processor_task
|
||||||
|
|
||||||
|
|
||||||
async def _run_user_scanner_with_scope(
|
async def _run_user_scanner_with_scope(
|
||||||
@@ -266,9 +349,10 @@ async def _run_user_scanner_with_scope(
|
|||||||
send_stream: MemoryObjectSendStream[DocumentTask],
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
shutdown_event: anyio.Event,
|
shutdown_event: anyio.Event,
|
||||||
wake_event: anyio.Event,
|
wake_event: anyio.Event,
|
||||||
token_broker: "TokenBrokerService",
|
token_broker: "TokenBrokerService | None",
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
user_states: dict[str, UserSyncState],
|
user_states: dict[str, UserSyncState],
|
||||||
|
use_basic_auth: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Wrapper to run scanner with cancellation scope.
|
"""Wrapper to run scanner with cancellation scope.
|
||||||
|
|
||||||
@@ -284,6 +368,7 @@ async def _run_user_scanner_with_scope(
|
|||||||
wake_event=wake_event,
|
wake_event=wake_event,
|
||||||
token_broker=token_broker,
|
token_broker=token_broker,
|
||||||
nextcloud_host=nextcloud_host,
|
nextcloud_host=nextcloud_host,
|
||||||
|
use_basic_auth=use_basic_auth,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
# Clean up on exit
|
# Clean up on exit
|
||||||
@@ -296,35 +381,40 @@ async def user_manager_task(
|
|||||||
send_stream: MemoryObjectSendStream[DocumentTask],
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
shutdown_event: anyio.Event,
|
shutdown_event: anyio.Event,
|
||||||
wake_event: anyio.Event,
|
wake_event: anyio.Event,
|
||||||
token_broker: "TokenBrokerService",
|
token_broker: "TokenBrokerService | None",
|
||||||
refresh_token_storage: "RefreshTokenStorage",
|
refresh_token_storage: "RefreshTokenStorage",
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
user_states: dict[str, UserSyncState],
|
user_states: dict[str, UserSyncState],
|
||||||
tg: TaskGroup,
|
tg: TaskGroup,
|
||||||
|
use_basic_auth: bool = False,
|
||||||
*,
|
*,
|
||||||
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Supervisor task that manages per-user scanners.
|
"""Supervisor task that manages per-user scanners.
|
||||||
|
|
||||||
Periodically polls RefreshTokenStorage to detect:
|
Periodically polls storage to detect:
|
||||||
- New users who have provisioned offline access -> start scanner
|
- New users who have provisioned access -> start scanner
|
||||||
- Users who have revoked access -> cancel their scanner
|
- Users who have revoked access -> cancel their scanner
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
send_stream: Stream to send documents to processors
|
send_stream: Stream to send documents to processors
|
||||||
shutdown_event: Event signaling shutdown
|
shutdown_event: Event signaling shutdown
|
||||||
wake_event: Event to wake scanners for immediate scan
|
wake_event: Event to wake scanners for immediate scan
|
||||||
token_broker: Token broker for obtaining access tokens
|
token_broker: Token broker for OAuth mode (None for BasicAuth mode)
|
||||||
refresh_token_storage: Storage for refresh tokens
|
refresh_token_storage: Storage for tracking provisioned users
|
||||||
nextcloud_host: Nextcloud base URL
|
nextcloud_host: Nextcloud base URL
|
||||||
user_states: Shared dict tracking active user scanners
|
user_states: Shared dict tracking active user scanners
|
||||||
tg: Task group for spawning scanner tasks
|
tg: Task group for spawning scanner tasks
|
||||||
|
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
||||||
task_status: Status object for signaling task readiness
|
task_status: Status object for signaling task readiness
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
poll_interval = settings.vector_sync_user_poll_interval
|
poll_interval = settings.vector_sync_user_poll_interval
|
||||||
|
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||||
|
|
||||||
logger.info(f"[OAuth] User manager started (poll interval: {poll_interval}s)")
|
logger.info(
|
||||||
|
f"[{mode_label}] User manager started (poll interval: {poll_interval}s)"
|
||||||
|
)
|
||||||
task_status.started()
|
task_status.started()
|
||||||
|
|
||||||
while not shutdown_event.is_set():
|
while not shutdown_event.is_set():
|
||||||
@@ -337,7 +427,7 @@ async def user_manager_task(
|
|||||||
new_users = provisioned_users - active_users
|
new_users = provisioned_users - active_users
|
||||||
for user_id in new_users:
|
for user_id in new_users:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[OAuth] Starting scanner for newly provisioned user: {user_id}"
|
f"[{mode_label}] Starting scanner for newly provisioned user: {user_id}"
|
||||||
)
|
)
|
||||||
cancel_scope = anyio.CancelScope()
|
cancel_scope = anyio.CancelScope()
|
||||||
user_states[user_id] = UserSyncState(
|
user_states[user_id] = UserSyncState(
|
||||||
@@ -356,24 +446,27 @@ async def user_manager_task(
|
|||||||
token_broker,
|
token_broker,
|
||||||
nextcloud_host,
|
nextcloud_host,
|
||||||
user_states,
|
user_states,
|
||||||
|
use_basic_auth, # Positional after user_states
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cancel scanners for revoked users
|
# Cancel scanners for revoked users
|
||||||
revoked_users = active_users - provisioned_users
|
revoked_users = active_users - provisioned_users
|
||||||
for user_id in revoked_users:
|
for user_id in revoked_users:
|
||||||
logger.info(f"[OAuth] Stopping scanner for revoked user: {user_id}")
|
logger.info(
|
||||||
|
f"[{mode_label}] Stopping scanner for revoked user: {user_id}"
|
||||||
|
)
|
||||||
state = user_states.get(user_id)
|
state = user_states.get(user_id)
|
||||||
if state:
|
if state:
|
||||||
state.cancel_scope.cancel()
|
state.cancel_scope.cancel()
|
||||||
# Note: state will be removed by _run_user_scanner_with_scope on exit
|
# Note: state will be removed by _run_user_scanner_with_scope on exit
|
||||||
|
|
||||||
if new_users:
|
if new_users:
|
||||||
logger.info(f"[OAuth] Started {len(new_users)} new scanner(s)")
|
logger.info(f"[{mode_label}] Started {len(new_users)} new scanner(s)")
|
||||||
if revoked_users:
|
if revoked_users:
|
||||||
logger.info(f"[OAuth] Stopped {len(revoked_users)} scanner(s)")
|
logger.info(f"[{mode_label}] Stopped {len(revoked_users)} scanner(s)")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[OAuth] User manager error: {e}", exc_info=True)
|
logger.error(f"[{mode_label}] User manager error: {e}", exc_info=True)
|
||||||
|
|
||||||
# Sleep until next poll
|
# Sleep until next poll
|
||||||
try:
|
try:
|
||||||
@@ -384,9 +477,9 @@ async def user_manager_task(
|
|||||||
|
|
||||||
# Cancel all remaining scanners on shutdown
|
# Cancel all remaining scanners on shutdown
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[OAuth] User manager shutting down, cancelling {len(user_states)} scanner(s)"
|
f"[{mode_label}] User manager shutting down, cancelling {len(user_states)} scanner(s)"
|
||||||
)
|
)
|
||||||
for state in list(user_states.values()):
|
for state in list(user_states.values()):
|
||||||
state.cancel_scope.cancel()
|
state.cancel_scope.cancel()
|
||||||
|
|
||||||
logger.info("[OAuth] User manager stopped")
|
logger.info(f"[{mode_label}] User manager stopped")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Processes documents from stream: fetches content, generates embeddings, stores in Qdrant.
|
Processes documents from stream: fetches content, generates embeddings, stores in Qdrant.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -585,8 +586,6 @@ async def _index_document(
|
|||||||
"vector_sync.pdf_size": len(content_bytes),
|
"vector_sync.pdf_size": len(content_bytes),
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
import base64
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
||||||
|
|
||||||
# Build chunk data for batch processing
|
# Build chunk data for batch processing
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Periodically scans enabled users' content and queues changed documents for proce
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -167,7 +168,6 @@ async def scan_user_documents(
|
|||||||
nc_client: Authenticated Nextcloud client
|
nc_client: Authenticated Nextcloud client
|
||||||
initial_sync: If True, send all documents (first-time sync)
|
initial_sync: If True, send all documents (first-time sync)
|
||||||
"""
|
"""
|
||||||
import random
|
|
||||||
|
|
||||||
scan_id = random.randint(1000, 9999)
|
scan_id = random.randint(1000, 9999)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.56.2"
|
version = "0.60.3"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
@@ -64,7 +64,7 @@ Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHAN
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
anyio_mode = "auto"
|
anyio_mode = "auto"
|
||||||
addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio
|
addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio
|
||||||
log_cli = 1
|
log_cli = 1
|
||||||
log_cli_level = "ERROR"
|
log_cli_level = "ERROR"
|
||||||
log_level = "ERROR"
|
log_level = "ERROR"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -22,14 +24,13 @@ def create_mock_response(
|
|||||||
Returns:
|
Returns:
|
||||||
Mock httpx.Response object
|
Mock httpx.Response object
|
||||||
"""
|
"""
|
||||||
import json as json_module
|
|
||||||
|
|
||||||
if headers is None:
|
if headers is None:
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
# If json_data is provided, serialize it to content
|
# If json_data is provided, serialize it to content
|
||||||
if json_data is not None:
|
if json_data is not None:
|
||||||
content = json_module.dumps(json_data).encode("utf-8")
|
content = json.dumps(json_data).encode("utf-8")
|
||||||
headers.setdefault("content-type", "application/json")
|
headers.setdefault("content-type", "application/json")
|
||||||
|
|
||||||
if content is None:
|
if content is None:
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for DeckClient.update_card API behavior.
|
||||||
|
|
||||||
|
These tests define the EXPECTED behavior for partial card updates:
|
||||||
|
- Only fields explicitly passed should be modified
|
||||||
|
- All other fields should be preserved unchanged
|
||||||
|
|
||||||
|
Related issues:
|
||||||
|
- nextcloud-mcp-server #452: DeckClient.update_card partial update bugs
|
||||||
|
- deck #3127: REST API Docs: missing parameter in "update cards"
|
||||||
|
- deck #4106: Provide a working example of API usage to update a cards details
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def deck_test_card(nc_client):
|
||||||
|
"""Create a board, stack, and card for testing, cleanup after."""
|
||||||
|
board = await nc_client.deck.create_board("Test Update Card API", "FF0000")
|
||||||
|
stack = await nc_client.deck.create_stack(board.id, "Test Stack", 1)
|
||||||
|
card = await nc_client.deck.create_card(
|
||||||
|
board.id,
|
||||||
|
stack.id,
|
||||||
|
"Original Title",
|
||||||
|
type="plain",
|
||||||
|
description="Original description",
|
||||||
|
)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"board_id": board.id,
|
||||||
|
"stack_id": stack.id,
|
||||||
|
"card_id": card.id,
|
||||||
|
"card": card,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
await nc_client.deck.delete_board(board.id)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeckClientUpdateCard:
|
||||||
|
"""
|
||||||
|
Test DeckClient.update_card() partial update behavior.
|
||||||
|
|
||||||
|
Expected: Only explicitly provided fields are updated, all others preserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_update_title_only_preserves_description(
|
||||||
|
self, nc_client, deck_test_card
|
||||||
|
):
|
||||||
|
"""Updating only the title should preserve the description."""
|
||||||
|
await nc_client.deck.update_card(
|
||||||
|
board_id=deck_test_card["board_id"],
|
||||||
|
stack_id=deck_test_card["stack_id"],
|
||||||
|
card_id=deck_test_card["card_id"],
|
||||||
|
title="New Title",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await nc_client.deck.get_card(
|
||||||
|
deck_test_card["board_id"],
|
||||||
|
deck_test_card["stack_id"],
|
||||||
|
deck_test_card["card_id"],
|
||||||
|
)
|
||||||
|
assert updated.title == "New Title"
|
||||||
|
assert updated.description == "Original description"
|
||||||
|
|
||||||
|
async def test_update_description_only(self, nc_client, deck_test_card):
|
||||||
|
"""Updating only the description should work and preserve other fields."""
|
||||||
|
await nc_client.deck.update_card(
|
||||||
|
board_id=deck_test_card["board_id"],
|
||||||
|
stack_id=deck_test_card["stack_id"],
|
||||||
|
card_id=deck_test_card["card_id"],
|
||||||
|
description="New description only",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await nc_client.deck.get_card(
|
||||||
|
deck_test_card["board_id"],
|
||||||
|
deck_test_card["stack_id"],
|
||||||
|
deck_test_card["card_id"],
|
||||||
|
)
|
||||||
|
assert updated.title == "Original Title"
|
||||||
|
assert updated.description == "New description only"
|
||||||
|
|
||||||
|
async def test_update_title_and_description(self, nc_client, deck_test_card):
|
||||||
|
"""Updating title and description together should work."""
|
||||||
|
await nc_client.deck.update_card(
|
||||||
|
board_id=deck_test_card["board_id"],
|
||||||
|
stack_id=deck_test_card["stack_id"],
|
||||||
|
card_id=deck_test_card["card_id"],
|
||||||
|
title="New Title",
|
||||||
|
description="New description",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await nc_client.deck.get_card(
|
||||||
|
deck_test_card["board_id"],
|
||||||
|
deck_test_card["stack_id"],
|
||||||
|
deck_test_card["card_id"],
|
||||||
|
)
|
||||||
|
assert updated.title == "New Title"
|
||||||
|
assert updated.description == "New description"
|
||||||
|
|
||||||
|
async def test_update_duedate_only(self, nc_client, deck_test_card):
|
||||||
|
"""Updating only the duedate should work and preserve other fields."""
|
||||||
|
await nc_client.deck.update_card(
|
||||||
|
board_id=deck_test_card["board_id"],
|
||||||
|
stack_id=deck_test_card["stack_id"],
|
||||||
|
card_id=deck_test_card["card_id"],
|
||||||
|
duedate="2025-12-31T23:59:59+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await nc_client.deck.get_card(
|
||||||
|
deck_test_card["board_id"],
|
||||||
|
deck_test_card["stack_id"],
|
||||||
|
deck_test_card["card_id"],
|
||||||
|
)
|
||||||
|
assert updated.title == "Original Title"
|
||||||
|
assert updated.description == "Original description"
|
||||||
|
assert updated.duedate is not None
|
||||||
|
|
||||||
|
async def test_update_archived_only(self, nc_client, deck_test_card):
|
||||||
|
"""Updating only the archived status should work and preserve other fields."""
|
||||||
|
await nc_client.deck.update_card(
|
||||||
|
board_id=deck_test_card["board_id"],
|
||||||
|
stack_id=deck_test_card["stack_id"],
|
||||||
|
card_id=deck_test_card["card_id"],
|
||||||
|
archived=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await nc_client.deck.get_card(
|
||||||
|
deck_test_card["board_id"],
|
||||||
|
deck_test_card["stack_id"],
|
||||||
|
deck_test_card["card_id"],
|
||||||
|
)
|
||||||
|
assert updated.title == "Original Title"
|
||||||
|
assert updated.description == "Original description"
|
||||||
|
assert updated.archived is True
|
||||||
|
|
||||||
|
async def test_update_order_only(self, nc_client, deck_test_card):
|
||||||
|
"""Updating only the order should work and preserve other fields."""
|
||||||
|
await nc_client.deck.update_card(
|
||||||
|
board_id=deck_test_card["board_id"],
|
||||||
|
stack_id=deck_test_card["stack_id"],
|
||||||
|
card_id=deck_test_card["card_id"],
|
||||||
|
order=99,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await nc_client.deck.get_card(
|
||||||
|
deck_test_card["board_id"],
|
||||||
|
deck_test_card["stack_id"],
|
||||||
|
deck_test_card["card_id"],
|
||||||
|
)
|
||||||
|
assert updated.title == "Original Title"
|
||||||
|
assert updated.description == "Original description"
|
||||||
|
assert updated.order == 99
|
||||||
|
|
||||||
|
async def test_update_preserves_type(self, nc_client, deck_test_card):
|
||||||
|
"""Type should be preserved when not explicitly changed."""
|
||||||
|
original = deck_test_card["card"]
|
||||||
|
|
||||||
|
await nc_client.deck.update_card(
|
||||||
|
board_id=deck_test_card["board_id"],
|
||||||
|
stack_id=deck_test_card["stack_id"],
|
||||||
|
card_id=deck_test_card["card_id"],
|
||||||
|
title="Changed Title",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await nc_client.deck.get_card(
|
||||||
|
deck_test_card["board_id"],
|
||||||
|
deck_test_card["stack_id"],
|
||||||
|
deck_test_card["card_id"],
|
||||||
|
)
|
||||||
|
assert updated.type == original.type
|
||||||
|
assert updated.description == "Original description"
|
||||||
|
|
||||||
|
async def test_update_preserves_owner(self, nc_client, deck_test_card):
|
||||||
|
"""Owner should be preserved when not explicitly changed."""
|
||||||
|
original = deck_test_card["card"]
|
||||||
|
|
||||||
|
await nc_client.deck.update_card(
|
||||||
|
board_id=deck_test_card["board_id"],
|
||||||
|
stack_id=deck_test_card["stack_id"],
|
||||||
|
card_id=deck_test_card["card_id"],
|
||||||
|
title="Changed Title",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await nc_client.deck.get_card(
|
||||||
|
deck_test_card["board_id"],
|
||||||
|
deck_test_card["stack_id"],
|
||||||
|
deck_test_card["card_id"],
|
||||||
|
)
|
||||||
|
assert updated.owner == original.owner
|
||||||
|
assert updated.description == "Original description"
|
||||||
+60
-31
@@ -1,7 +1,17 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
from urllib.parse import parse_qs, quote, urlparse
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import httpx
|
import httpx
|
||||||
@@ -257,7 +267,6 @@ async def nc_mcp_basic_auth_client(
|
|||||||
|
|
||||||
Uses anyio pytest plugin for proper async fixture handling.
|
Uses anyio pytest plugin for proper async fixture handling.
|
||||||
"""
|
"""
|
||||||
import base64
|
|
||||||
|
|
||||||
credentials = base64.b64encode(b"admin:admin").decode("utf-8")
|
credentials = base64.b64encode(b"admin:admin").decode("utf-8")
|
||||||
auth_header = f"Basic {credentials}"
|
auth_header = f"Basic {credentials}"
|
||||||
@@ -342,7 +351,6 @@ async def nc_mcp_oauth_client_with_elicitation(
|
|||||||
logger.info(f" Schema: {params.schema}")
|
logger.info(f" Schema: {params.schema}")
|
||||||
|
|
||||||
# Extract OAuth URL from elicitation message
|
# Extract OAuth URL from elicitation message
|
||||||
import re
|
|
||||||
|
|
||||||
url_pattern = r"https?://[^\s]+"
|
url_pattern = r"https?://[^\s]+"
|
||||||
urls = re.findall(url_pattern, params.message)
|
urls = re.findall(url_pattern, params.message)
|
||||||
@@ -1108,10 +1116,6 @@ def oauth_callback_server():
|
|||||||
# "OAuth tests with browser automation not supported in GitHub Actions CI"
|
# "OAuth tests with browser automation not supported in GitHub Actions CI"
|
||||||
# )
|
# )
|
||||||
|
|
||||||
import threading
|
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
# Use a dict to store auth codes keyed by state parameter
|
# Use a dict to store auth codes keyed by state parameter
|
||||||
# This allows multiple concurrent OAuth flows
|
# This allows multiple concurrent OAuth flows
|
||||||
auth_states = {}
|
auth_states = {}
|
||||||
@@ -1758,9 +1762,6 @@ async def playwright_oauth_token(
|
|||||||
- Browser fixture provided by pytest-playwright-asyncio
|
- Browser fixture provided by pytest-playwright-asyncio
|
||||||
- See: https://playwright.dev/python/docs/test-runners
|
- See: https://playwright.dev/python/docs/test-runners
|
||||||
"""
|
"""
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
@@ -2047,9 +2048,6 @@ async def _get_oauth_token_with_scopes(
|
|||||||
Returns:
|
Returns:
|
||||||
OAuth access token string with requested scopes
|
OAuth access token string with requested scopes
|
||||||
"""
|
"""
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
@@ -2320,7 +2318,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 = []
|
created_users = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2414,9 +2415,6 @@ async def _get_oauth_token_for_user(
|
|||||||
Returns:
|
Returns:
|
||||||
OAuth access token string
|
OAuth access token string
|
||||||
"""
|
"""
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
|
||||||
@@ -2557,7 +2555,6 @@ async def all_oauth_tokens(
|
|||||||
Now uses the real callback server with state parameters for reliable
|
Now uses the real callback server with state parameters for reliable
|
||||||
concurrent token acquisition without race conditions.
|
concurrent token acquisition without race conditions.
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
|
|
||||||
# Get auth_states dict from callback server
|
# Get auth_states dict from callback server
|
||||||
auth_states, callback_url = oauth_callback_server
|
auth_states, callback_url = oauth_callback_server
|
||||||
@@ -2708,7 +2705,6 @@ async def test_user(nc_client: NextcloudClient):
|
|||||||
user_config = test_user
|
user_config = test_user
|
||||||
await nc_client.users.create_user(**user_config)
|
await nc_client.users.create_user(**user_config)
|
||||||
"""
|
"""
|
||||||
import uuid
|
|
||||||
|
|
||||||
# Generate unique user ID to avoid conflicts
|
# Generate unique user ID to avoid conflicts
|
||||||
userid = f"testuser_{uuid.uuid4().hex[:8]}"
|
userid = f"testuser_{uuid.uuid4().hex[:8]}"
|
||||||
@@ -2744,7 +2740,6 @@ async def test_group(nc_client: NextcloudClient):
|
|||||||
|
|
||||||
Returns the group ID.
|
Returns the group ID.
|
||||||
"""
|
"""
|
||||||
import uuid
|
|
||||||
|
|
||||||
# Generate unique group ID to avoid conflicts
|
# Generate unique group ID to avoid conflicts
|
||||||
groupid = f"testgroup_{uuid.uuid4().hex[:8]}"
|
groupid = f"testgroup_{uuid.uuid4().hex[:8]}"
|
||||||
@@ -2879,11 +2874,6 @@ async def _get_keycloak_oauth_token(
|
|||||||
Returns:
|
Returns:
|
||||||
OAuth access token string from Keycloak
|
OAuth access token string from Keycloak
|
||||||
"""
|
"""
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
# Get auth_states dict from callback server
|
# Get auth_states dict from callback server
|
||||||
auth_states, _ = oauth_callback_server
|
auth_states, _ = oauth_callback_server
|
||||||
@@ -3249,8 +3239,6 @@ async def configure_astrolabe_for_mcp_server(nc_client):
|
|||||||
- mcp_server_public_url: Public URL for OAuth token audience validation
|
- mcp_server_public_url: Public URL for OAuth token audience validation
|
||||||
- client_id: Optional OAuth client ID (default: "nextcloudMcpServerUIPublicClient")
|
- client_id: Optional OAuth client ID (default: "nextcloudMcpServerUIPublicClient")
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
async def _configure(
|
async def _configure(
|
||||||
mcp_server_internal_url: str,
|
mcp_server_internal_url: str,
|
||||||
@@ -3267,7 +3255,7 @@ async def configure_astrolabe_for_mcp_server(nc_client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Configure MCP server URLs in Nextcloud system config
|
# Configure MCP server URLs in Nextcloud system config
|
||||||
subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"docker",
|
"docker",
|
||||||
"compose",
|
"compose",
|
||||||
@@ -3281,11 +3269,45 @@ async def configure_astrolabe_for_mcp_server(nc_client):
|
|||||||
"--value",
|
"--value",
|
||||||
mcp_server_internal_url,
|
mcp_server_internal_url,
|
||||||
],
|
],
|
||||||
check=True,
|
|
||||||
capture_output=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",
|
"docker",
|
||||||
"compose",
|
"compose",
|
||||||
@@ -3299,11 +3321,18 @@ async def configure_astrolabe_for_mcp_server(nc_client):
|
|||||||
"--value",
|
"--value",
|
||||||
mcp_server_public_url,
|
mcp_server_public_url,
|
||||||
],
|
],
|
||||||
check=True,
|
|
||||||
capture_output=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
|
# Remove existing OAuth client if it exists
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Integration tests for document processing with progress notifications."""
|
"""Integration tests for document processing with progress notifications."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -13,7 +14,6 @@ class TestDocumentProcessingProgress:
|
|||||||
|
|
||||||
async def test_unstructured_processor_with_progress_callback(self, nc_client):
|
async def test_unstructured_processor_with_progress_callback(self, nc_client):
|
||||||
"""Test that UnstructuredProcessor calls progress callback during processing."""
|
"""Test that UnstructuredProcessor calls progress callback during processing."""
|
||||||
import os
|
|
||||||
|
|
||||||
# Skip if unstructured is not enabled
|
# Skip if unstructured is not enabled
|
||||||
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
|
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
|
||||||
@@ -71,7 +71,6 @@ class TestDocumentProcessingProgress:
|
|||||||
self, nc_mcp_client, nc_client
|
self, nc_mcp_client, nc_client
|
||||||
):
|
):
|
||||||
"""Test that reading a document via WebDAV MCP tool sends progress notifications."""
|
"""Test that reading a document via WebDAV MCP tool sends progress notifications."""
|
||||||
import os
|
|
||||||
|
|
||||||
# Skip if document processing is not enabled
|
# Skip if document processing is not enabled
|
||||||
if os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() != "true":
|
if os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() != "true":
|
||||||
@@ -110,7 +109,6 @@ class TestDocumentProcessingProgress:
|
|||||||
|
|
||||||
async def test_progress_callback_not_required(self, nc_client):
|
async def test_progress_callback_not_required(self, nc_client):
|
||||||
"""Test that processing works without progress callback (backward compatibility)."""
|
"""Test that processing works without progress callback (backward compatibility)."""
|
||||||
import os
|
|
||||||
|
|
||||||
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
|
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
|
||||||
pytest.skip("Unstructured processor not enabled")
|
pytest.skip("Unstructured processor not enabled")
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ This conftest.py provides hooks and fixtures specific to integration tests,
|
|||||||
including the --provider flag for RAG tests.
|
including the --provider flag for RAG tests.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Valid provider names
|
# Valid provider names
|
||||||
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
|
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
|
||||||
|
|
||||||
@@ -24,3 +30,89 @@ def pytest_configure(config):
|
|||||||
config.addinivalue_line(
|
config.addinivalue_line(
|
||||||
"markers", "rag: mark test as RAG integration test (requires --provider flag)"
|
"markers", "rag: mark test as RAG integration test (requires --provider flag)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True, scope="module")
|
||||||
|
async def reset_all_singletons():
|
||||||
|
"""Reset ALL global singletons between test modules.
|
||||||
|
|
||||||
|
Prevents anyio.WouldBlock errors caused by stale singleton state
|
||||||
|
from previous test modules holding references to dead event loops
|
||||||
|
or closed memory streams.
|
||||||
|
"""
|
||||||
|
# Import all modules with singletons
|
||||||
|
import nextcloud_mcp_server.app as app_module
|
||||||
|
import nextcloud_mcp_server.auth.client_registry as client_registry_module
|
||||||
|
import nextcloud_mcp_server.auth.token_exchange as token_exchange_module
|
||||||
|
import nextcloud_mcp_server.embedding.service as embedding_module
|
||||||
|
import nextcloud_mcp_server.observability.tracing as tracing_module
|
||||||
|
import nextcloud_mcp_server.providers.registry as registry_module
|
||||||
|
import nextcloud_mcp_server.vector.qdrant_client as qdrant_module
|
||||||
|
|
||||||
|
# Store originals for restoration after test
|
||||||
|
originals = {
|
||||||
|
"qdrant_client": qdrant_module._qdrant_client,
|
||||||
|
"embedding_service": embedding_module._embedding_service,
|
||||||
|
"bm25_service": embedding_module._bm25_service,
|
||||||
|
"provider": registry_module._provider,
|
||||||
|
"vector_sync_state": (
|
||||||
|
app_module._vector_sync_state.document_send_stream,
|
||||||
|
app_module._vector_sync_state.document_receive_stream,
|
||||||
|
app_module._vector_sync_state.shutdown_event,
|
||||||
|
app_module._vector_sync_state.scanner_wake_event,
|
||||||
|
),
|
||||||
|
"tracer": tracing_module._tracer,
|
||||||
|
"registry": client_registry_module._registry,
|
||||||
|
"token_exchange_service": token_exchange_module._token_exchange_service,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Close any open memory streams before reset
|
||||||
|
if app_module._vector_sync_state.document_send_stream is not None:
|
||||||
|
try:
|
||||||
|
await app_module._vector_sync_state.document_send_stream.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if app_module._vector_sync_state.document_receive_stream is not None:
|
||||||
|
try:
|
||||||
|
await app_module._vector_sync_state.document_receive_stream.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reset all singletons to None/fresh state
|
||||||
|
qdrant_module._qdrant_client = None
|
||||||
|
embedding_module._embedding_service = None
|
||||||
|
embedding_module._bm25_service = None
|
||||||
|
registry_module._provider = None
|
||||||
|
app_module._vector_sync_state.document_send_stream = None
|
||||||
|
app_module._vector_sync_state.document_receive_stream = None
|
||||||
|
app_module._vector_sync_state.shutdown_event = None
|
||||||
|
app_module._vector_sync_state.scanner_wake_event = None
|
||||||
|
tracing_module._tracer = None
|
||||||
|
client_registry_module._registry = None
|
||||||
|
token_exchange_module._token_exchange_service = None
|
||||||
|
|
||||||
|
logger.debug("All singletons reset for test module")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Cleanup: Close async resources created during test
|
||||||
|
if qdrant_module._qdrant_client is not None:
|
||||||
|
try:
|
||||||
|
await qdrant_module._qdrant_client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Restore originals
|
||||||
|
qdrant_module._qdrant_client = originals["qdrant_client"]
|
||||||
|
embedding_module._embedding_service = originals["embedding_service"]
|
||||||
|
embedding_module._bm25_service = originals["bm25_service"]
|
||||||
|
registry_module._provider = originals["provider"]
|
||||||
|
(
|
||||||
|
app_module._vector_sync_state.document_send_stream,
|
||||||
|
app_module._vector_sync_state.document_receive_stream,
|
||||||
|
app_module._vector_sync_state.shutdown_event,
|
||||||
|
app_module._vector_sync_state.scanner_wake_event,
|
||||||
|
) = originals["vector_sync_state"]
|
||||||
|
tracing_module._tracer = originals["tracer"]
|
||||||
|
client_registry_module._registry = originals["registry"]
|
||||||
|
token_exchange_module._token_exchange_service = originals["token_exchange_service"]
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
"""Integration tests for app password provisioning via Astrolabe.
|
"""Integration tests for app password provisioning via Astrolabe.
|
||||||
|
|
||||||
Tests the complete flow:
|
Tests the complete flow for multi-user BasicAuth mode:
|
||||||
1. User stores app password via Astrolabe API
|
1. User stores app password via Astrolabe API
|
||||||
2. MCP server retrieves it via OAuth client credentials
|
2. MCP server retrieves it via OAuth client credentials
|
||||||
3. Background sync uses it to access Nextcloud
|
3. Background sync uses it to access Nextcloud (NOT OAuth refresh tokens)
|
||||||
|
|
||||||
|
These tests verify that BasicAuth and OAuth are completely separate concerns
|
||||||
|
with no fallback between them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import BasicAuth
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
from nextcloud_mcp_server.vector.oauth_sync import get_user_client
|
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||||
|
NotProvisionedError,
|
||||||
|
get_user_client,
|
||||||
|
get_user_client_basic_auth,
|
||||||
|
get_user_client_oauth,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -77,9 +84,20 @@ async def test_get_user_app_password_returns_none_for_unconfigured_user():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_dual_credential_support_in_background_sync(mocker):
|
async def test_basic_auth_mode_uses_app_password_only(mocker):
|
||||||
"""Test that background sync tries app password first, then refresh token."""
|
"""Test that BasicAuth mode uses ONLY app passwords, NOT OAuth tokens.
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
|
||||||
|
In multi-user BasicAuth mode, OAuth refresh tokens are NOT used.
|
||||||
|
This is a complete separation of concerns.
|
||||||
|
"""
|
||||||
|
# Mock settings to have client credentials
|
||||||
|
mock_settings = mocker.MagicMock()
|
||||||
|
mock_settings.oidc_client_id = "test-client-id"
|
||||||
|
mock_settings.oidc_client_secret = "test-client-secret"
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
|
)
|
||||||
|
|
||||||
# Mock AstrolabeClient to return an app password
|
# Mock AstrolabeClient to return an app password
|
||||||
mock_astrolabe = mocker.AsyncMock()
|
mock_astrolabe = mocker.AsyncMock()
|
||||||
@@ -90,35 +108,36 @@ async def test_dual_credential_support_in_background_sync(mocker):
|
|||||||
return_value=mock_astrolabe,
|
return_value=mock_astrolabe,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock TokenBrokerService (shouldn't be called if app password works)
|
# Call get_user_client in BasicAuth mode
|
||||||
mock_token_broker = mocker.MagicMock(spec=TokenBrokerService)
|
_client = await get_user_client(
|
||||||
|
user_id="test_user",
|
||||||
|
token_broker=None, # No token broker needed for BasicAuth mode
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
use_basic_auth=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Call get_user_client - should use app password
|
# Verify app password was requested
|
||||||
try:
|
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
|
||||||
_client = await get_user_client(
|
|
||||||
user_id="test_user",
|
|
||||||
token_broker=mock_token_broker,
|
|
||||||
nextcloud_host="http://localhost:8080",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify app password was requested
|
# Verify client was created successfully with correct username
|
||||||
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
|
assert _client is not None
|
||||||
|
assert _client.username == "test_user"
|
||||||
# Verify token broker was NOT called (app password took priority)
|
|
||||||
mock_token_broker.get_background_token.assert_not_called()
|
|
||||||
|
|
||||||
# Verify client uses BasicAuth
|
|
||||||
assert _client.auth is not None
|
|
||||||
assert isinstance(_client.auth, BasicAuth)
|
|
||||||
except Exception:
|
|
||||||
# May fail in test environment, but we verified the priority logic
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_background_sync_falls_back_to_refresh_token(mocker):
|
async def test_basic_auth_mode_raises_error_without_app_password(mocker):
|
||||||
"""Test that background sync falls back to refresh token if no app password."""
|
"""Test that BasicAuth mode raises NotProvisionedError if no app password.
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
|
||||||
|
There is NO fallback to OAuth - if no app password, user must provision one.
|
||||||
|
"""
|
||||||
|
# Mock settings to have client credentials
|
||||||
|
mock_settings = mocker.MagicMock()
|
||||||
|
mock_settings.oidc_client_id = "test-client-id"
|
||||||
|
mock_settings.oidc_client_secret = "test-client-secret"
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
|
)
|
||||||
|
|
||||||
# Mock AstrolabeClient to return None (no app password)
|
# Mock AstrolabeClient to return None (no app password)
|
||||||
mock_astrolabe = mocker.AsyncMock()
|
mock_astrolabe = mocker.AsyncMock()
|
||||||
@@ -129,23 +148,131 @@ async def test_background_sync_falls_back_to_refresh_token(mocker):
|
|||||||
return_value=mock_astrolabe,
|
return_value=mock_astrolabe,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Call get_user_client in BasicAuth mode - should raise NotProvisionedError
|
||||||
|
with pytest.raises(NotProvisionedError) as exc_info:
|
||||||
|
await get_user_client(
|
||||||
|
user_id="test_user",
|
||||||
|
token_broker=None,
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
use_basic_auth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify error message mentions app password provisioning
|
||||||
|
assert "app password" in str(exc_info.value).lower()
|
||||||
|
assert "test_user" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_oauth_mode_uses_refresh_token_only(mocker):
|
||||||
|
"""Test that OAuth mode uses ONLY refresh tokens, NOT app passwords.
|
||||||
|
|
||||||
|
In OAuth mode, app passwords are NOT used.
|
||||||
|
This is a complete separation of concerns.
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
|
|
||||||
# Mock TokenBrokerService to return an access token
|
# Mock TokenBrokerService to return an access token
|
||||||
mock_token_broker = mocker.AsyncMock(spec=TokenBrokerService)
|
mock_token_broker = mocker.AsyncMock(spec=TokenBrokerService)
|
||||||
mock_token_broker.get_background_token.return_value = "test-access-token"
|
mock_token_broker.get_background_token.return_value = "test-access-token"
|
||||||
|
|
||||||
# Call get_user_client - should fall back to refresh token
|
# Call get_user_client in OAuth mode
|
||||||
try:
|
_client = await get_user_client(
|
||||||
_client = await get_user_client(
|
user_id="test_user",
|
||||||
|
token_broker=mock_token_broker,
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
use_basic_auth=False, # OAuth mode
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify token broker was called (NOT Astrolabe)
|
||||||
|
mock_token_broker.get_background_token.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_oauth_mode_raises_error_without_token(mocker):
|
||||||
|
"""Test that OAuth mode raises NotProvisionedError if no refresh token.
|
||||||
|
|
||||||
|
There is NO fallback to app passwords - if no token, user must provision.
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
|
|
||||||
|
# Mock TokenBrokerService to return None (no token)
|
||||||
|
mock_token_broker = mocker.AsyncMock(spec=TokenBrokerService)
|
||||||
|
mock_token_broker.get_background_token.return_value = None
|
||||||
|
|
||||||
|
# Call get_user_client in OAuth mode - should raise NotProvisionedError
|
||||||
|
with pytest.raises(NotProvisionedError) as exc_info:
|
||||||
|
await get_user_client(
|
||||||
user_id="test_user",
|
user_id="test_user",
|
||||||
token_broker=mock_token_broker,
|
token_broker=mock_token_broker,
|
||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
|
use_basic_auth=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify app password was attempted first
|
# Verify error message mentions OAuth provisioning
|
||||||
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
|
assert "oauth" in str(exc_info.value).lower()
|
||||||
|
assert "test_user" in str(exc_info.value)
|
||||||
|
|
||||||
# Verify token broker was called as fallback
|
|
||||||
mock_token_broker.get_background_token.assert_called_once()
|
@pytest.mark.integration
|
||||||
except Exception:
|
async def test_get_user_client_basic_auth_function(mocker):
|
||||||
# May fail in test environment, but we verified the fallback logic
|
"""Test the dedicated get_user_client_basic_auth function."""
|
||||||
pass
|
# Mock settings to have client credentials
|
||||||
|
mock_settings = mocker.MagicMock()
|
||||||
|
mock_settings.oidc_client_id = "test-client-id"
|
||||||
|
mock_settings.oidc_client_secret = "test-client-secret"
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock AstrolabeClient
|
||||||
|
mock_astrolabe = mocker.AsyncMock()
|
||||||
|
mock_astrolabe.get_user_app_password.return_value = "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||||
|
return_value=mock_astrolabe,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call dedicated function
|
||||||
|
client = await get_user_client_basic_auth(
|
||||||
|
user_id="alice",
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client is not None
|
||||||
|
assert client.username == "alice"
|
||||||
|
mock_astrolabe.get_user_app_password.assert_called_once_with("alice")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_get_user_client_oauth_function(mocker):
|
||||||
|
"""Test the dedicated get_user_client_oauth function."""
|
||||||
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
|
|
||||||
|
# Mock TokenBrokerService
|
||||||
|
mock_token_broker = mocker.AsyncMock(spec=TokenBrokerService)
|
||||||
|
mock_token_broker.get_background_token.return_value = "test-bearer-token"
|
||||||
|
|
||||||
|
# Call dedicated function
|
||||||
|
client = await get_user_client_oauth(
|
||||||
|
user_id="alice",
|
||||||
|
token_broker=mock_token_broker,
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client is not None
|
||||||
|
assert client.username == "alice"
|
||||||
|
mock_token_broker.get_background_token.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_oauth_mode_requires_token_broker():
|
||||||
|
"""Test that OAuth mode requires a token broker."""
|
||||||
|
with pytest.raises(ValueError, match="token_broker required"):
|
||||||
|
await get_user_client(
|
||||||
|
user_id="test_user",
|
||||||
|
token_broker=None, # Missing token broker
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
use_basic_auth=False, # OAuth mode
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,817 @@
|
|||||||
|
"""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 re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if not re.match(
|
||||||
|
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$",
|
||||||
|
app_password,
|
||||||
|
):
|
||||||
|
logger.error(
|
||||||
|
f"Extracted password does not match expected format: '{app_password}'"
|
||||||
|
)
|
||||||
|
logger.error(f"Password repr: {repr(app_password)}")
|
||||||
|
screenshot_path = f"/tmp/app_password_invalid_format_{username}.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
raise ValueError(
|
||||||
|
f"App password format validation failed. Screenshot: {screenshot_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✓ Generated app password for {username}: {app_password[:10]}... (validated)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Close the dialog by clicking the Close button
|
||||||
|
close_button = page.get_by_role("button", name="Close")
|
||||||
|
await close_button.click()
|
||||||
|
logger.info("Closed app password dialog")
|
||||||
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
|
return app_password
|
||||||
|
|
||||||
|
|
||||||
|
async def enable_background_sync_via_app_password(
|
||||||
|
page: Page, username: str, app_password: str
|
||||||
|
):
|
||||||
|
"""Enable background sync by entering app password in Astrolabe settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Playwright page instance
|
||||||
|
username: Username (for logging)
|
||||||
|
app_password: App password to enter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if background sync was enabled successfully
|
||||||
|
"""
|
||||||
|
logger.info(f"Enabling background sync via app password for {username}...")
|
||||||
|
|
||||||
|
nextcloud_url = "http://localhost:8080"
|
||||||
|
|
||||||
|
# Set up network request and console listeners BEFORE navigation
|
||||||
|
network_requests = []
|
||||||
|
network_responses = []
|
||||||
|
console_messages = []
|
||||||
|
|
||||||
|
def log_request(req):
|
||||||
|
network_requests.append(f"{req.method} {req.url}")
|
||||||
|
|
||||||
|
def log_response(resp):
|
||||||
|
response_info = f"{resp.status} {resp.url}"
|
||||||
|
network_responses.append(response_info)
|
||||||
|
logger.info(f"Response: {response_info}")
|
||||||
|
|
||||||
|
def log_console(msg):
|
||||||
|
console_messages.append(f"[{msg.type}] {msg.text}")
|
||||||
|
|
||||||
|
page.on("request", log_request)
|
||||||
|
page.on("response", log_response)
|
||||||
|
page.on("console", log_console)
|
||||||
|
|
||||||
|
# Navigate to Astrolabe settings
|
||||||
|
await page.goto(
|
||||||
|
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for page to load
|
||||||
|
await anyio.sleep(1)
|
||||||
|
|
||||||
|
# Check if already active (look for "Active" text in the Background Sync Access section)
|
||||||
|
try:
|
||||||
|
# The "Active" badge appears as a <span> with text "Active"
|
||||||
|
active_text = page.get_by_text("Active", exact=True)
|
||||||
|
if await active_text.is_visible(timeout=2000):
|
||||||
|
logger.info(f"✓ Background sync already active for {username}")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Find the app password input field using the placeholder text
|
||||||
|
# Based on manual testing: textbox with placeholder "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||||
|
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await app_password_input.wait_for(timeout=5000, state="visible")
|
||||||
|
logger.info("Found app password input field")
|
||||||
|
except Exception:
|
||||||
|
# Take screenshot for debugging
|
||||||
|
screenshot_path = f"/tmp/astrolabe_no_password_field_{username}.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
raise ValueError(
|
||||||
|
f"Could not find app password input field for {username}. Screenshot: {screenshot_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enter the app password
|
||||||
|
await app_password_input.fill(app_password)
|
||||||
|
logger.info(f"Entered app password for {username}")
|
||||||
|
|
||||||
|
# Wait a moment for any validation to complete
|
||||||
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
|
# Take screenshot before clicking Save to check for warnings
|
||||||
|
screenshot_path = f"/tmp/before_save_{username}.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
logger.info(f"Screenshot taken before Save: {screenshot_path}")
|
||||||
|
|
||||||
|
# Find and click the Save button
|
||||||
|
save_button = page.get_by_role("button", name="Save")
|
||||||
|
|
||||||
|
# Check if Save button is enabled
|
||||||
|
is_disabled = await save_button.is_disabled()
|
||||||
|
logger.info(f"Save button disabled state: {is_disabled}")
|
||||||
|
|
||||||
|
await save_button.click()
|
||||||
|
logger.info("Clicked Save button")
|
||||||
|
|
||||||
|
# Give the request time to complete before checking logs
|
||||||
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
|
# Log network requests after clicking Save
|
||||||
|
logger.info(f"Network requests after Save for {username}:")
|
||||||
|
for req in network_requests[-10:]: # Last 10 requests
|
||||||
|
logger.info(f" {req}")
|
||||||
|
|
||||||
|
# Log network responses after clicking Save
|
||||||
|
logger.info(f"Network responses after Save for {username}:")
|
||||||
|
for resp in network_responses[-10:]: # Last 10 responses
|
||||||
|
logger.info(f" {resp}")
|
||||||
|
|
||||||
|
# Check specifically for the credentials POST response
|
||||||
|
credentials_responses = [
|
||||||
|
r for r in network_responses if "background-sync/credentials" in r
|
||||||
|
]
|
||||||
|
if credentials_responses:
|
||||||
|
logger.info(f"Credentials endpoint response: {credentials_responses[-1]}")
|
||||||
|
if "200" not in credentials_responses[-1]:
|
||||||
|
logger.error(
|
||||||
|
f"Credentials POST did not return 200 OK: {credentials_responses[-1]}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("No response found for credentials endpoint!")
|
||||||
|
|
||||||
|
# Wait for the page to reload after successful save
|
||||||
|
# The JavaScript in personalSettings.js does: setTimeout(() => window.location.reload(), 1000)
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||||
|
await anyio.sleep(2)
|
||||||
|
|
||||||
|
# Log any console messages
|
||||||
|
if console_messages:
|
||||||
|
logger.info(f"Console messages for {username}:")
|
||||||
|
for msg in console_messages:
|
||||||
|
logger.info(f" {msg}")
|
||||||
|
|
||||||
|
# Check for error notifications (toast messages)
|
||||||
|
try:
|
||||||
|
error_toast = page.locator(".toastify.toast-error, .toast-error")
|
||||||
|
if await error_toast.count() > 0:
|
||||||
|
error_text = await error_toast.first.text_content()
|
||||||
|
logger.error(f"Error notification for {username}: {error_text}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Verify "Active" text appears after reload
|
||||||
|
try:
|
||||||
|
active_text = page.get_by_text("Active", exact=True)
|
||||||
|
await active_text.wait_for(timeout=5000, state="visible")
|
||||||
|
logger.info(f"✓ Background sync enabled for {username} - Active badge visible")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
# Take screenshot for debugging
|
||||||
|
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
logger.error(
|
||||||
|
f"Active badge did not appear for {username}. Screenshot: {screenshot_path}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_app_password_created(username: str) -> bool:
|
||||||
|
"""Verify that background sync app password was stored for the user.
|
||||||
|
|
||||||
|
This checks the Nextcloud database for background sync credentials stored
|
||||||
|
by Astrolabe in the oc_preferences table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Nextcloud username
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if background sync app password exists
|
||||||
|
"""
|
||||||
|
logger.info(f"Verifying background sync app password for {username}...")
|
||||||
|
|
||||||
|
# Query the database to check for background sync credentials
|
||||||
|
# Astrolabe stores app passwords in oc_preferences, not oc_authtoken
|
||||||
|
|
||||||
|
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!"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||||
|
"""Revoke background sync access by clicking the Revoke Access button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Playwright page instance (must be authenticated)
|
||||||
|
username: Username (for logging)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if revocation was successful
|
||||||
|
"""
|
||||||
|
logger.info(f"Revoking background sync access for {username}...")
|
||||||
|
|
||||||
|
nextcloud_url = "http://localhost:8080"
|
||||||
|
|
||||||
|
# Set up network request and console listeners
|
||||||
|
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 "Active" badge is visible (indicating background sync is enabled)
|
||||||
|
try:
|
||||||
|
active_text = page.get_by_text("Active", exact=True)
|
||||||
|
if not await active_text.is_visible(timeout=2000):
|
||||||
|
logger.warning(
|
||||||
|
f"Background sync not active for {username}, nothing to revoke"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
logger.warning(f"Could not find Active badge for {username}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Find the "Revoke Access" button
|
||||||
|
revoke_button = page.get_by_role("button", name="Revoke Access")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await revoke_button.wait_for(timeout=5000, state="visible")
|
||||||
|
logger.info("Found Revoke Access button")
|
||||||
|
except Exception:
|
||||||
|
screenshot_path = f"/tmp/astrolabe_no_revoke_button_{username}.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
raise ValueError(
|
||||||
|
f"Could not find Revoke Access button for {username}. Screenshot: {screenshot_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up dialog handler for confirmation dialog
|
||||||
|
page.once("dialog", lambda dialog: dialog.accept())
|
||||||
|
|
||||||
|
# Click the Revoke Access button
|
||||||
|
await revoke_button.click()
|
||||||
|
logger.info("Clicked Revoke Access button")
|
||||||
|
|
||||||
|
# Wait for the request to complete and page to reload
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||||
|
await anyio.sleep(2)
|
||||||
|
|
||||||
|
# Log network requests after clicking
|
||||||
|
logger.info(f"Network requests after Revoke for {username}:")
|
||||||
|
for req in network_requests[-10:]:
|
||||||
|
logger.info(f" {req}")
|
||||||
|
|
||||||
|
# Log network responses
|
||||||
|
logger.info(f"Network responses after Revoke for {username}:")
|
||||||
|
for resp in network_responses[-10:]:
|
||||||
|
logger.info(f" {resp}")
|
||||||
|
|
||||||
|
# Check specifically for the revoke POST response
|
||||||
|
revoke_responses = [r for r in network_responses if "credentials/revoke" in r]
|
||||||
|
if revoke_responses:
|
||||||
|
logger.info(f"Revoke endpoint response: {revoke_responses[-1]}")
|
||||||
|
if "200" not in revoke_responses[-1]:
|
||||||
|
logger.error(f"Revoke POST did not return 200 OK: {revoke_responses[-1]}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.warning("No response found for credentials/revoke endpoint!")
|
||||||
|
# Take screenshot for debugging
|
||||||
|
screenshot_path = f"/tmp/astrolabe_revoke_no_response_{username}.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Verify "Active" badge is no longer visible
|
||||||
|
try:
|
||||||
|
active_text = page.get_by_text("Active", exact=True)
|
||||||
|
if await active_text.is_visible(timeout=2000):
|
||||||
|
logger.error(f"Active badge still visible for {username} after revoke!")
|
||||||
|
screenshot_path = f"/tmp/astrolabe_revoke_still_active_{username}.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"✓ Background sync access revoked for {username}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_app_password_deleted(username: str) -> bool:
|
||||||
|
"""Verify that background sync app password was deleted for the user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Nextcloud username
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if background sync credentials no longer exist
|
||||||
|
"""
|
||||||
|
logger.info(f"Verifying background sync credentials deleted for {username}...")
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# After deletion, we should NOT see background_sync_password
|
||||||
|
if "background_sync_password" not in output:
|
||||||
|
logger.info(f"✓ Background sync credentials deleted for {username}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Background sync credentials still exist 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_revoke_background_sync_access(
|
||||||
|
browser,
|
||||||
|
nc_client,
|
||||||
|
test_users_setup,
|
||||||
|
configure_astrolabe_for_mcp_server,
|
||||||
|
):
|
||||||
|
"""Test that users can revoke background sync access via the Revoke Access button.
|
||||||
|
|
||||||
|
This test verifies:
|
||||||
|
1. User enables background sync via app password
|
||||||
|
2. User clicks "Revoke Access" button
|
||||||
|
3. Confirmation dialog is handled
|
||||||
|
4. POST request is sent to /api/v1/background-sync/credentials/revoke
|
||||||
|
5. "Active" badge disappears from settings page
|
||||||
|
6. Background sync credentials are deleted from database
|
||||||
|
|
||||||
|
This tests the fix for the issue where POST requests to the revoke endpoint
|
||||||
|
were returning errors due to HTTP method mismatch (was DELETE, now POST).
|
||||||
|
"""
|
||||||
|
# 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 with a single user for this specific test
|
||||||
|
username = "alice"
|
||||||
|
user_config = test_users_setup[username]
|
||||||
|
password = user_config["password"]
|
||||||
|
|
||||||
|
# Create new browser context
|
||||||
|
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 and enable background sync
|
||||||
|
app_password = await generate_app_password(page, username)
|
||||||
|
await enable_background_sync_via_app_password(page, username, app_password)
|
||||||
|
|
||||||
|
# Step 3: Verify background sync is enabled
|
||||||
|
assert await verify_app_password_created(username), (
|
||||||
|
f"Background sync not enabled for {username}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Revoke background sync access
|
||||||
|
revoke_success = await revoke_background_sync_access(page, username)
|
||||||
|
assert revoke_success, f"Failed to revoke background sync access for {username}"
|
||||||
|
|
||||||
|
# Step 5: Verify credentials are deleted from database
|
||||||
|
credentials_deleted = await verify_app_password_deleted(username)
|
||||||
|
assert credentials_deleted, (
|
||||||
|
f"Background sync credentials not deleted for {username}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"\n✓ Successfully revoked background sync access for {username}!")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await context.close()
|
||||||
@@ -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
|
||||||
@@ -4,18 +4,26 @@ Tests that BasicAuth credentials are extracted from request headers
|
|||||||
and passed through to Nextcloud APIs without storage (stateless).
|
and passed through to Nextcloud APIs without storage (stateless).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_basic_auth_pass_through_notes_list(nc_mcp_basic_auth_client):
|
async def test_basic_auth_pass_through_notes_search(nc_mcp_basic_auth_client):
|
||||||
"""Test BasicAuth pass-through with notes list tool."""
|
"""Test BasicAuth pass-through with notes search tool."""
|
||||||
# Call tool - BasicAuth header is set at connection level by fixture
|
# Call tool - BasicAuth header is set at connection level by fixture
|
||||||
response = await nc_mcp_basic_auth_client.call_tool("nc_notes_list", {})
|
response = await nc_mcp_basic_auth_client.call_tool(
|
||||||
|
"nc_notes_search_notes", {"query": "test"}
|
||||||
|
)
|
||||||
|
|
||||||
# Verify tool executed successfully with pass-through auth
|
# Verify tool executed successfully with pass-through auth
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert "results" in response or "content" in response
|
assert not response.isError, f"Tool returned error: {response.content}"
|
||||||
|
# Response should have content with results
|
||||||
|
assert len(response.content) > 0
|
||||||
|
data = json.loads(response.content[0].text)
|
||||||
|
assert "results" in data
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -23,7 +31,7 @@ async def test_basic_auth_pass_through_notes_create(nc_mcp_basic_auth_client):
|
|||||||
"""Test BasicAuth pass-through with notes create tool."""
|
"""Test BasicAuth pass-through with notes create tool."""
|
||||||
# Create a note using BasicAuth
|
# Create a note using BasicAuth
|
||||||
response = await nc_mcp_basic_auth_client.call_tool(
|
response = await nc_mcp_basic_auth_client.call_tool(
|
||||||
"nc_notes_create",
|
"nc_notes_create_note",
|
||||||
{
|
{
|
||||||
"title": "BasicAuth Test Note",
|
"title": "BasicAuth Test Note",
|
||||||
"content": "This note was created via BasicAuth pass-through",
|
"content": "This note was created via BasicAuth pass-through",
|
||||||
@@ -32,16 +40,35 @@ async def test_basic_auth_pass_through_notes_create(nc_mcp_basic_auth_client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert response.get("success") is True or "note_id" in response
|
assert not response.isError, f"Tool returned error: {response.content}"
|
||||||
|
# Parse response and verify note was created
|
||||||
|
data = json.loads(response.content[0].text)
|
||||||
|
assert data.get("success") is True or "note_id" in data
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_basic_auth_pass_through_search(nc_mcp_basic_auth_client):
|
async def test_basic_auth_pass_through_get_note(nc_mcp_basic_auth_client):
|
||||||
"""Test BasicAuth pass-through with search tool."""
|
"""Test BasicAuth pass-through with get note tool."""
|
||||||
# Search notes using BasicAuth
|
# First create a note to get
|
||||||
|
create_response = await nc_mcp_basic_auth_client.call_tool(
|
||||||
|
"nc_notes_create_note",
|
||||||
|
{
|
||||||
|
"title": "BasicAuth Get Test",
|
||||||
|
"content": "Note to retrieve",
|
||||||
|
"category": "Test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert not create_response.isError
|
||||||
|
create_data = json.loads(create_response.content[0].text)
|
||||||
|
note_id = create_data.get("id")
|
||||||
|
|
||||||
|
# Now get the note using BasicAuth
|
||||||
response = await nc_mcp_basic_auth_client.call_tool(
|
response = await nc_mcp_basic_auth_client.call_tool(
|
||||||
"nc_notes_search", {"query": "BasicAuth"}
|
"nc_notes_get_note", {"note_id": note_id}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert "results" in response or "content" in response
|
assert not response.isError, f"Tool returned error: {response.content}"
|
||||||
|
data = json.loads(response.content[0].text)
|
||||||
|
# Nextcloud may append a number to duplicate titles
|
||||||
|
assert data.get("title", "").startswith("BasicAuth Get Test")
|
||||||
|
|||||||
@@ -10,12 +10,21 @@ These tests validate that:
|
|||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from qdrant_client.models import VectorParams
|
||||||
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
def get_vector_params(collection_info) -> VectorParams:
|
||||||
|
"""Get vector params from collection info, handling named vectors format."""
|
||||||
|
vectors = collection_info.config.params.vectors
|
||||||
|
if isinstance(vectors, dict):
|
||||||
|
return vectors["dense"]
|
||||||
|
return vectors
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def reset_singleton():
|
async def reset_singleton():
|
||||||
"""Reset the global Qdrant client singleton between tests."""
|
"""Reset the global Qdrant client singleton between tests."""
|
||||||
@@ -75,7 +84,7 @@ async def test_collection_auto_created_on_first_access(monkeypatch):
|
|||||||
|
|
||||||
# Verify collection has correct dimensions
|
# Verify collection has correct dimensions
|
||||||
collection_info = await client.get_collection(collection_name)
|
collection_info = await client.get_collection(collection_name)
|
||||||
assert collection_info.config.params.vectors.size == 384
|
assert get_vector_params(collection_info).size == 384
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -127,7 +136,7 @@ async def test_existing_collection_reused(monkeypatch):
|
|||||||
|
|
||||||
# Verify dimensions unchanged
|
# Verify dimensions unchanged
|
||||||
collection_info = await client2.get_collection(collection_name)
|
collection_info = await client2.get_collection(collection_name)
|
||||||
assert collection_info.config.params.vectors.size == 384
|
assert get_vector_params(collection_info).size == 384
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -164,7 +173,7 @@ async def test_dimension_mismatch_detected(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
# Verify collection created
|
# Verify collection created
|
||||||
collection_info = await client1.get_collection(collection_name)
|
collection_info = await client1.get_collection(collection_name)
|
||||||
assert collection_info.config.params.vectors.size == 384
|
assert get_vector_params(collection_info).size == 384
|
||||||
|
|
||||||
# Close client1 to release file lock
|
# Close client1 to release file lock
|
||||||
await client1.close()
|
await client1.close()
|
||||||
@@ -248,12 +257,10 @@ async def test_collection_name_generation(monkeypatch):
|
|||||||
mock_settings = Settings(
|
mock_settings = Settings(
|
||||||
qdrant_location=":memory:",
|
qdrant_location=":memory:",
|
||||||
ollama_embedding_model="test-model",
|
ollama_embedding_model="test-model",
|
||||||
|
otel_service_name="test-deployment",
|
||||||
vector_sync_enabled=False,
|
vector_sync_enabled=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock deployment ID
|
|
||||||
monkeypatch.setenv("MCP_DEPLOYMENT_ID", "test-deployment")
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nextcloud_mcp_server.vector.qdrant_client.get_settings", lambda: mock_settings
|
"nextcloud_mcp_server.vector.qdrant_client.get_settings", lambda: mock_settings
|
||||||
)
|
)
|
||||||
@@ -319,4 +326,4 @@ async def test_collection_uses_cosine_distance(monkeypatch):
|
|||||||
|
|
||||||
from qdrant_client.models import Distance
|
from qdrant_client.models import Distance
|
||||||
|
|
||||||
assert collection_info.config.params.vectors.distance == Distance.COSINE
|
assert get_vector_params(collection_info).distance == Distance.COSINE
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ logger = logging.getLogger(__name__)
|
|||||||
DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
|
DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
async def require_vector_sync_tools(nc_mcp_client):
|
||||||
|
"""Skip test if vector sync tools are not available."""
|
||||||
|
tools = await nc_mcp_client.list_tools()
|
||||||
|
tool_names = [t.name for t in tools.tools]
|
||||||
|
if "nc_get_vector_sync_status" not in tool_names:
|
||||||
|
pytest.skip("Vector sync tools not available (VECTOR_SYNC_ENABLED not set)")
|
||||||
|
|
||||||
|
|
||||||
async def llm_judge(
|
async def llm_judge(
|
||||||
provider: Provider,
|
provider: Provider,
|
||||||
ground_truth: str,
|
ground_truth: str,
|
||||||
@@ -116,6 +124,8 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
|||||||
Environment Variables:
|
Environment Variables:
|
||||||
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: Nextcloud Manual.pdf)
|
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: Nextcloud Manual.pdf)
|
||||||
"""
|
"""
|
||||||
|
await require_vector_sync_tools(nc_mcp_client)
|
||||||
|
|
||||||
manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH)
|
manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH)
|
||||||
|
|
||||||
logger.info(f"Setting up indexed manual PDF: {manual_path}")
|
logger.info(f"Setting up indexed manual PDF: {manual_path}")
|
||||||
@@ -152,7 +162,7 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not result.isError:
|
if not result.isError:
|
||||||
content = result.structuredContent or {}
|
content = json.loads(result.content[0].text) if result.content else {}
|
||||||
indexed = content.get("indexed_count", 0)
|
indexed = content.get("indexed_count", 0)
|
||||||
pending = content.get("pending_count", 1)
|
pending = content.get("pending_count", 1)
|
||||||
|
|
||||||
@@ -248,7 +258,7 @@ async def test_semantic_search_retrieval(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.isError is False, f"Tool call failed: {result}"
|
assert result.isError is False, f"Tool call failed: {result}"
|
||||||
data = result.structuredContent
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
# Verify we got results
|
# Verify we got results
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
@@ -295,7 +305,7 @@ async def test_semantic_search_answer_with_sampling(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.isError is False, f"Tool call failed: {result}"
|
assert result.isError is False, f"Tool call failed: {result}"
|
||||||
data = result.structuredContent
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
# Verify response structure
|
# Verify response structure
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
@@ -369,7 +379,7 @@ async def test_retrieval_quality_all_queries(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.isError is False
|
assert result.isError is False
|
||||||
data = result.structuredContent
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
assert data["total_found"] >= min_expected_results, (
|
assert data["total_found"] >= min_expected_results, (
|
||||||
f"Query '{query}' returned {data['total_found']} results, "
|
f"Query '{query}' returned {data['total_found']} results, "
|
||||||
@@ -393,7 +403,7 @@ async def test_no_results_for_unrelated_query(nc_mcp_client, indexed_manual_pdf)
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.isError is False
|
assert result.isError is False
|
||||||
data = result.structuredContent
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
# Should have few or no high-scoring results
|
# Should have few or no high-scoring results
|
||||||
# Low score threshold means we might get some results, but they should be low quality
|
# Low score threshold means we might get some results, but they should be low quality
|
||||||
|
|||||||
@@ -13,14 +13,24 @@ Note: These tests require VECTOR_SYNC_ENABLED=true and a configured
|
|||||||
vector database with indexed test data.
|
vector database with indexed test data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
from mcp.types import CreateMessageResult, TextContent
|
from mcp.types import CreateMessageResult, TextContent
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
async def require_vector_sync_tools(nc_mcp_client):
|
||||||
|
"""Skip test if vector sync tools are not available."""
|
||||||
|
tools = await nc_mcp_client.list_tools()
|
||||||
|
tool_names = [t.name for t in tools.tools]
|
||||||
|
if "nc_get_vector_sync_status" not in tool_names:
|
||||||
|
pytest.skip("Vector sync tools not available (VECTOR_SYNC_ENABLED not set)")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_sampling_result():
|
def mock_sampling_result():
|
||||||
"""Mock successful sampling result from MCP client."""
|
"""Mock successful sampling result from MCP client."""
|
||||||
@@ -55,13 +65,14 @@ async def test_semantic_search_answer_successful_sampling(
|
|||||||
4. Mock ctx.session.create_message to return answer
|
4. Mock ctx.session.create_message to return answer
|
||||||
5. Verify response contains generated answer and sources
|
5. Verify response contains generated answer and sources
|
||||||
"""
|
"""
|
||||||
|
await require_vector_sync_tools(nc_mcp_client)
|
||||||
|
|
||||||
# Get initial indexed count before creating note
|
# Get initial indexed count before creating note
|
||||||
import asyncio
|
|
||||||
|
|
||||||
initial_sync = await nc_mcp_client.call_tool(
|
initial_sync = await nc_mcp_client.call_tool(
|
||||||
"nc_get_vector_sync_status", arguments={}
|
"nc_get_vector_sync_status", arguments={}
|
||||||
)
|
)
|
||||||
initial_indexed_count = initial_sync.structuredContent["indexed_count"]
|
initial_indexed_count = json.loads(initial_sync.content[0].text)["indexed_count"]
|
||||||
print(f"Initial indexed count: {initial_indexed_count}")
|
print(f"Initial indexed count: {initial_indexed_count}")
|
||||||
|
|
||||||
# Create a note with content about Python async
|
# Create a note with content about Python async
|
||||||
@@ -90,7 +101,7 @@ Avoid blocking operations in async code.""",
|
|||||||
sync_status = await nc_mcp_client.call_tool(
|
sync_status = await nc_mcp_client.call_tool(
|
||||||
"nc_get_vector_sync_status", arguments={}
|
"nc_get_vector_sync_status", arguments={}
|
||||||
)
|
)
|
||||||
status_data = sync_status.structuredContent
|
status_data = json.loads(sync_status.content[0].text)
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"Sync status at {waited}s: indexed={status_data['indexed_count']}, pending={status_data['pending_count']}, status={status_data['status']}"
|
f"Sync status at {waited}s: indexed={status_data['indexed_count']}, pending={status_data['pending_count']}, status={status_data['status']}"
|
||||||
@@ -107,7 +118,7 @@ Avoid blocking operations in async code.""",
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
await asyncio.sleep(wait_interval)
|
await anyio.sleep(wait_interval)
|
||||||
waited += wait_interval
|
waited += wait_interval
|
||||||
|
|
||||||
# Verify sync completed
|
# Verify sync completed
|
||||||
@@ -135,7 +146,7 @@ Avoid blocking operations in async code.""",
|
|||||||
assert call_result.isError is False, (
|
assert call_result.isError is False, (
|
||||||
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
||||||
)
|
)
|
||||||
result = call_result.structuredContent
|
result = json.loads(call_result.content[0].text)
|
||||||
|
|
||||||
# Verify response structure
|
# Verify response structure
|
||||||
assert result is not None
|
assert result is not None
|
||||||
@@ -179,6 +190,8 @@ async def test_semantic_search_answer_no_results(nc_mcp_client):
|
|||||||
2. Verify response indicates no documents found
|
2. Verify response indicates no documents found
|
||||||
3. Verify no sampling call was made (no sources to base answer on)
|
3. Verify no sampling call was made (no sources to base answer on)
|
||||||
"""
|
"""
|
||||||
|
await require_vector_sync_tools(nc_mcp_client)
|
||||||
|
|
||||||
call_result = await nc_mcp_client.call_tool(
|
call_result = await nc_mcp_client.call_tool(
|
||||||
"nc_semantic_search_answer",
|
"nc_semantic_search_answer",
|
||||||
arguments={
|
arguments={
|
||||||
@@ -192,7 +205,7 @@ async def test_semantic_search_answer_no_results(nc_mcp_client):
|
|||||||
assert call_result.isError is False, (
|
assert call_result.isError is False, (
|
||||||
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
||||||
)
|
)
|
||||||
result = call_result.structuredContent
|
result = json.loads(call_result.content[0].text)
|
||||||
|
|
||||||
# Should get "no documents found" message
|
# Should get "no documents found" message
|
||||||
assert result is not None
|
assert result is not None
|
||||||
@@ -214,6 +227,8 @@ async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_f
|
|||||||
3. Query with limit=2
|
3. Query with limit=2
|
||||||
4. Verify at most 2 sources in response
|
4. Verify at most 2 sources in response
|
||||||
"""
|
"""
|
||||||
|
await require_vector_sync_tools(nc_mcp_client)
|
||||||
|
|
||||||
# Create multiple related notes
|
# Create multiple related notes
|
||||||
_note1 = await temporary_note_factory(
|
_note1 = await temporary_note_factory(
|
||||||
title="Python Async Part 1",
|
title="Python Async Part 1",
|
||||||
@@ -232,7 +247,6 @@ async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_f
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Wait for vector indexing to complete
|
# Wait for vector indexing to complete
|
||||||
import asyncio
|
|
||||||
|
|
||||||
max_wait = 30
|
max_wait = 30
|
||||||
wait_interval = 1
|
wait_interval = 1
|
||||||
@@ -242,12 +256,12 @@ async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_f
|
|||||||
sync_status = await nc_mcp_client.call_tool(
|
sync_status = await nc_mcp_client.call_tool(
|
||||||
"nc_get_vector_sync_status", arguments={}
|
"nc_get_vector_sync_status", arguments={}
|
||||||
)
|
)
|
||||||
status_data = sync_status.structuredContent
|
status_data = json.loads(sync_status.content[0].text)
|
||||||
|
|
||||||
if status_data["status"] == "idle" and status_data["pending_count"] == 0:
|
if status_data["status"] == "idle" and status_data["pending_count"] == 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
await asyncio.sleep(wait_interval)
|
await anyio.sleep(wait_interval)
|
||||||
waited += wait_interval
|
waited += wait_interval
|
||||||
|
|
||||||
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
|
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
|
||||||
@@ -265,7 +279,7 @@ async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_f
|
|||||||
assert call_result.isError is False, (
|
assert call_result.isError is False, (
|
||||||
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
||||||
)
|
)
|
||||||
result = call_result.structuredContent
|
result = json.loads(call_result.content[0].text)
|
||||||
|
|
||||||
# Should respect limit
|
# Should respect limit
|
||||||
assert len(result["sources"]) <= 2
|
assert len(result["sources"]) <= 2
|
||||||
@@ -282,6 +296,8 @@ async def test_semantic_search_answer_score_threshold(
|
|||||||
3. Query with high threshold (0.9)
|
3. Query with high threshold (0.9)
|
||||||
4. Verify only high-scoring results returned
|
4. Verify only high-scoring results returned
|
||||||
"""
|
"""
|
||||||
|
await require_vector_sync_tools(nc_mcp_client)
|
||||||
|
|
||||||
_note = await temporary_note_factory(
|
_note = await temporary_note_factory(
|
||||||
title="Exact Match Test",
|
title="Exact Match Test",
|
||||||
content="This is a very specific test document about widget manufacturing",
|
content="This is a very specific test document about widget manufacturing",
|
||||||
@@ -289,7 +305,6 @@ async def test_semantic_search_answer_score_threshold(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Wait for vector indexing to complete
|
# Wait for vector indexing to complete
|
||||||
import asyncio
|
|
||||||
|
|
||||||
max_wait = 30
|
max_wait = 30
|
||||||
wait_interval = 1
|
wait_interval = 1
|
||||||
@@ -299,12 +314,12 @@ async def test_semantic_search_answer_score_threshold(
|
|||||||
sync_status = await nc_mcp_client.call_tool(
|
sync_status = await nc_mcp_client.call_tool(
|
||||||
"nc_get_vector_sync_status", arguments={}
|
"nc_get_vector_sync_status", arguments={}
|
||||||
)
|
)
|
||||||
status_data = sync_status.structuredContent
|
status_data = json.loads(sync_status.content[0].text)
|
||||||
|
|
||||||
if status_data["status"] == "idle" and status_data["pending_count"] == 0:
|
if status_data["status"] == "idle" and status_data["pending_count"] == 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
await asyncio.sleep(wait_interval)
|
await anyio.sleep(wait_interval)
|
||||||
waited += wait_interval
|
waited += wait_interval
|
||||||
|
|
||||||
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
|
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
|
||||||
@@ -323,7 +338,7 @@ async def test_semantic_search_answer_score_threshold(
|
|||||||
assert call_result.isError is False, (
|
assert call_result.isError is False, (
|
||||||
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
||||||
)
|
)
|
||||||
result = call_result.structuredContent
|
result = json.loads(call_result.content[0].text)
|
||||||
|
|
||||||
# Note: Semantic search scores depend on embedding model
|
# Note: Semantic search scores depend on embedding model
|
||||||
# We just verify the tool accepts the parameter
|
# We just verify the tool accepts the parameter
|
||||||
@@ -345,6 +360,8 @@ async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_f
|
|||||||
Note: Token limiting is enforced by the MCP client's LLM, not the server.
|
Note: Token limiting is enforced by the MCP client's LLM, not the server.
|
||||||
This test just verifies the parameter is correctly passed.
|
This test just verifies the parameter is correctly passed.
|
||||||
"""
|
"""
|
||||||
|
await require_vector_sync_tools(nc_mcp_client)
|
||||||
|
|
||||||
_note = await temporary_note_factory(
|
_note = await temporary_note_factory(
|
||||||
title="Long Document",
|
title="Long Document",
|
||||||
content="This is a document with lots of content. " * 50,
|
content="This is a document with lots of content. " * 50,
|
||||||
@@ -352,7 +369,6 @@ async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_f
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Wait for vector indexing to complete
|
# Wait for vector indexing to complete
|
||||||
import asyncio
|
|
||||||
|
|
||||||
max_wait = 30
|
max_wait = 30
|
||||||
wait_interval = 1
|
wait_interval = 1
|
||||||
@@ -362,12 +378,12 @@ async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_f
|
|||||||
sync_status = await nc_mcp_client.call_tool(
|
sync_status = await nc_mcp_client.call_tool(
|
||||||
"nc_get_vector_sync_status", arguments={}
|
"nc_get_vector_sync_status", arguments={}
|
||||||
)
|
)
|
||||||
status_data = sync_status.structuredContent
|
status_data = json.loads(sync_status.content[0].text)
|
||||||
|
|
||||||
if status_data["status"] == "idle" and status_data["pending_count"] == 0:
|
if status_data["status"] == "idle" and status_data["pending_count"] == 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
await asyncio.sleep(wait_interval)
|
await anyio.sleep(wait_interval)
|
||||||
waited += wait_interval
|
waited += wait_interval
|
||||||
|
|
||||||
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
|
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
|
||||||
@@ -386,7 +402,7 @@ async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_f
|
|||||||
assert call_result.isError is False, (
|
assert call_result.isError is False, (
|
||||||
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
||||||
)
|
)
|
||||||
result = call_result.structuredContent
|
result = json.loads(call_result.content[0].text)
|
||||||
|
|
||||||
# Should not error, even if sampling fails
|
# Should not error, even if sampling fails
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Uses SimpleEmbeddingProvider for deterministic, in-process embeddings
|
|||||||
without requiring external services like Ollama.
|
without requiring external services like Ollama.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -147,7 +148,6 @@ async def test_simple_embedding_provider_deterministic(simple_embedding_provider
|
|||||||
assert len(embedding1) == 384
|
assert len(embedding1) == 384
|
||||||
|
|
||||||
# Should be normalized (unit length)
|
# Should be normalized (unit length)
|
||||||
import math
|
|
||||||
|
|
||||||
norm = math.sqrt(sum(x * x for x in embedding1))
|
norm = math.sqrt(sum(x * x for x in embedding1))
|
||||||
assert abs(norm - 1.0) < 1e-6
|
assert abs(norm - 1.0) < 1e-6
|
||||||
@@ -340,7 +340,6 @@ async def test_batch_embedding(simple_embedding_provider: SimpleEmbeddingProvide
|
|||||||
assert all(len(emb) == 384 for emb in embeddings)
|
assert all(len(emb) == 384 for emb in embeddings)
|
||||||
|
|
||||||
# Each should be normalized
|
# Each should be normalized
|
||||||
import math
|
|
||||||
|
|
||||||
for emb in embeddings:
|
for emb in embeddings:
|
||||||
norm = math.sqrt(sum(x * x for x in emb))
|
norm = math.sqrt(sum(x * x for x in emb))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ workflow completion rates, and cross-user operation latencies.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import statistics
|
import statistics
|
||||||
|
import time
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -44,13 +45,11 @@ class OAuthBenchmarkMetrics:
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Mark the start of the benchmark."""
|
"""Mark the start of the benchmark."""
|
||||||
import time
|
|
||||||
|
|
||||||
self.start_time = time.time()
|
self.start_time = time.time()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Mark the end of the benchmark."""
|
"""Mark the end of the benchmark."""
|
||||||
import time
|
|
||||||
|
|
||||||
self.end_time = time.time()
|
self.end_time = time.time()
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ Manages multiple OAuth-authenticated users for realistic multi-user load testing
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import httpx
|
import httpx
|
||||||
@@ -333,8 +337,6 @@ class OAuthUserPool:
|
|||||||
TimeoutError: If callback not received within timeout
|
TimeoutError: If callback not received within timeout
|
||||||
ValueError: If token exchange fails
|
ValueError: If token exchange fails
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
logger.info(f"Starting Playwright OAuth flow for {username}...")
|
logger.info(f"Starting Playwright OAuth flow for {username}...")
|
||||||
logger.debug(f"Using state: {state[:16]}...")
|
logger.debug(f"Using state: {state[:16]}...")
|
||||||
@@ -478,8 +480,6 @@ class UserSessionWrapper:
|
|||||||
|
|
||||||
def generate_secure_password(length: int = 20) -> str:
|
def generate_secure_password(length: int = 20) -> str:
|
||||||
"""Generate a secure random password."""
|
"""Generate a secure random password."""
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
|
|
||||||
alphabet = string.ascii_letters + string.digits + "!@#$%^&*()"
|
alphabet = string.ascii_letters + string.digits + "!@#$%^&*()"
|
||||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Workload definitions for load testing the MCP server.
|
|||||||
Defines realistic operation mixes and individual operation functions.
|
Defines realistic operation mixes and individual operation functions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
@@ -91,8 +92,6 @@ class WorkloadOperations:
|
|||||||
if result and len(result.content) > 0:
|
if result and len(result.content) > 0:
|
||||||
content = result.content[0]
|
content = result.content[0]
|
||||||
if hasattr(content, "text"):
|
if hasattr(content, "text"):
|
||||||
import json
|
|
||||||
|
|
||||||
note_data = json.loads(content.text)
|
note_data = json.loads(content.text)
|
||||||
note_id = note_data.get("id")
|
note_id = note_data.get("id")
|
||||||
if note_id:
|
if note_id:
|
||||||
@@ -222,8 +221,6 @@ class MixedWorkload:
|
|||||||
"nc_notes_get_note", {"note_id": note_id}
|
"nc_notes_get_note", {"note_id": note_id}
|
||||||
)
|
)
|
||||||
if get_result and len(get_result.content) > 0:
|
if get_result and len(get_result.content) > 0:
|
||||||
import json
|
|
||||||
|
|
||||||
note_data = json.loads(get_result.content[0].text)
|
note_data = json.loads(get_result.content[0].text)
|
||||||
etag = note_data.get("etag", "")
|
etag = note_data.get("etag", "")
|
||||||
self._warmup_note_ids.append((note_id, etag))
|
self._warmup_note_ids.append((note_id, etag))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Usage:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Add parent directory to path
|
# Add parent directory to path
|
||||||
@@ -127,7 +128,6 @@ async def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Extract requesttoken from HTML
|
# Extract requesttoken from HTML
|
||||||
import re
|
|
||||||
|
|
||||||
token_match = re.search(r'data-requesttoken="([^"]+)"', settings_response.text)
|
token_match = re.search(r'data-requesttoken="([^"]+)"', settings_response.text)
|
||||||
if token_match:
|
if token_match:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Architecture:
|
|||||||
MCP Client → Keycloak DCR → Keycloak OAuth → MCP Server → Nextcloud APIs
|
MCP Client → Keycloak DCR → Keycloak OAuth → MCP Server → Nextcloud APIs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
@@ -623,7 +624,6 @@ async def test_keycloak_dcr_architecture():
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Keycloak DCR Architecture:")
|
logger.info("Keycloak DCR Architecture:")
|
||||||
import json
|
|
||||||
|
|
||||||
logger.info(json.dumps(architecture, indent=2))
|
logger.info(json.dumps(architecture, indent=2))
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server
|
|||||||
|
|
||||||
# Navigate to settings
|
# Navigate to settings
|
||||||
logger.info("Navigating to personal MCP settings...")
|
logger.info("Navigating to personal MCP settings...")
|
||||||
await page.goto(f"{nextcloud_host}/settings/user/mcp")
|
await page.goto(f"{nextcloud_host}/settings/user/astrolabe")
|
||||||
await page.wait_for_load_state("networkidle")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
# Capture page content
|
# Capture page content
|
||||||
@@ -52,13 +52,13 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server
|
|||||||
logger.info(f"Page URL: {page.url}")
|
logger.info(f"Page URL: {page.url}")
|
||||||
logger.info(f"Page title: {await page.title()}")
|
logger.info(f"Page title: {await page.title()}")
|
||||||
|
|
||||||
# Check for key strings
|
# Check for key strings (Vue 3 UI)
|
||||||
checks = [
|
checks = [
|
||||||
"Authorize Access",
|
"Enable Semantic Search", # oauth-required.php authorization button
|
||||||
"Authorization Required",
|
"Service Status", # personal.php when authorized
|
||||||
"MCP Server",
|
"Background Sync Access", # personal.php when authorized
|
||||||
"Sign In Again",
|
"What happens next?", # oauth-required.php steps
|
||||||
"astrolabe",
|
"Astrolabe", # Header
|
||||||
]
|
]
|
||||||
|
|
||||||
for check in checks:
|
for check in checks:
|
||||||
@@ -75,6 +75,15 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server
|
|||||||
for i, link_text in enumerate(links[:10]):
|
for i, link_text in enumerate(links[:10]):
|
||||||
logger.info(f" Link {i}: {link_text}")
|
logger.info(f" Link {i}: {link_text}")
|
||||||
|
|
||||||
|
# Check the Enable Semantic Search button href
|
||||||
|
try:
|
||||||
|
btn = page.locator('a:has-text("Enable Semantic Search")')
|
||||||
|
if await btn.count() > 0:
|
||||||
|
href = await btn.get_attribute("href")
|
||||||
|
logger.info(f"Enable Semantic Search button href: {href}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not get button href: {e}")
|
||||||
|
|
||||||
# Check for error messages
|
# Check for error messages
|
||||||
if "error" in page_content.lower():
|
if "error" in page_content.lower():
|
||||||
logger.warning("Page contains 'error' keyword")
|
logger.warning("Page contains 'error' keyword")
|
||||||
|
|||||||
@@ -105,21 +105,26 @@ async def authorized_nc_session(
|
|||||||
|
|
||||||
# Step 2: Navigate to personal MCP settings
|
# Step 2: Navigate to personal MCP settings
|
||||||
logger.info("Navigating to personal MCP settings...")
|
logger.info("Navigating to personal MCP settings...")
|
||||||
await page.goto(f"{host}/settings/user/mcp")
|
await page.goto(f"{host}/settings/user/astrolabe")
|
||||||
await page.wait_for_load_state("networkidle")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
|
|
||||||
# Step 3: Check if authorization is needed
|
# Step 3: Check if authorization is needed
|
||||||
if "Authorize Access" in page_content or "authorize" in page_content.lower():
|
# Vue 3 UI shows "Enable Semantic Search" when not authorized
|
||||||
|
if (
|
||||||
|
"Enable Semantic Search" in page_content
|
||||||
|
or "What happens next?" in page_content
|
||||||
|
):
|
||||||
logger.info("User not authorized yet - initiating OAuth flow...")
|
logger.info("User not authorized yet - initiating OAuth flow...")
|
||||||
|
|
||||||
# Click "Authorize Access" button
|
# Click "Enable Semantic Search" button (Vue 3 template text)
|
||||||
authorize_selectors = [
|
authorize_selectors = [
|
||||||
'button:has-text("Authorize")',
|
'a:has-text("Enable Semantic Search")',
|
||||||
'a:has-text("Authorize")',
|
'button:has-text("Enable Semantic Search")',
|
||||||
'[href*="oauth/authorize"]',
|
'a:has-text("Sign In Again")',
|
||||||
'button:has-text("Connect")',
|
"a.button.primary",
|
||||||
|
'[href*="oauth/login"]',
|
||||||
]
|
]
|
||||||
|
|
||||||
clicked = False
|
clicked = False
|
||||||
@@ -142,9 +147,17 @@ async def authorized_nc_session(
|
|||||||
# Wait for page to load after clicking
|
# Wait for page to load after clicking
|
||||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||||
current_url = page.url
|
current_url = page.url
|
||||||
|
logger.info(f"After clicking authorize, current URL: {current_url}")
|
||||||
|
|
||||||
|
# Take screenshot for debugging
|
||||||
|
await page.screenshot(path="/tmp/nc-php-app-after-authorize-click.png")
|
||||||
|
logger.info("Screenshot saved to /tmp/nc-php-app-after-authorize-click.png")
|
||||||
|
|
||||||
# Handle OAuth consent if needed
|
# Handle OAuth consent if needed
|
||||||
if "/apps/oidc/authorize" in current_url:
|
if (
|
||||||
|
"/apps/oidc/authorize" in current_url
|
||||||
|
or "/apps/oidc/consent" in current_url
|
||||||
|
):
|
||||||
logger.info("On OIDC authorization page - granting consent...")
|
logger.info("On OIDC authorization page - granting consent...")
|
||||||
|
|
||||||
consent_selectors = [
|
consent_selectors = [
|
||||||
@@ -163,7 +176,7 @@ async def authorized_nc_session(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Wait for redirect back to settings
|
# Wait for redirect back to settings
|
||||||
await page.wait_for_url(f"{host}/settings/user/mcp", timeout=15000)
|
await page.wait_for_url(f"{host}/settings/user/astrolabe", timeout=15000)
|
||||||
await page.wait_for_load_state("networkidle")
|
await page.wait_for_load_state("networkidle")
|
||||||
logger.info("✓ OAuth authorization completed")
|
logger.info("✓ OAuth authorization completed")
|
||||||
|
|
||||||
@@ -197,28 +210,32 @@ class TestNcPhpAppOAuth:
|
|||||||
host = authorized_nc_session["host"]
|
host = authorized_nc_session["host"]
|
||||||
|
|
||||||
# Navigate to settings (may already be there)
|
# Navigate to settings (may already be there)
|
||||||
await page.goto(f"{host}/settings/user/mcp")
|
await page.goto(f"{host}/settings/user/astrolabe")
|
||||||
await page.wait_for_load_state("networkidle")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
|
|
||||||
# Look for indicators that authorization succeeded
|
# Look for indicators that authorization succeeded (Vue 3 personal.php template)
|
||||||
|
# These must be unique to the authorized state (not found in oauth-required.php)
|
||||||
success_indicators = [
|
success_indicators = [
|
||||||
"Connected",
|
"Service Status",
|
||||||
"Disconnect",
|
"Background Sync Access",
|
||||||
"Server Connection",
|
"Manage Connection",
|
||||||
"Session Information",
|
"Revoke Access",
|
||||||
"MCP Server",
|
"Service URL",
|
||||||
]
|
]
|
||||||
|
|
||||||
has_success_indicator = any(
|
found_indicators = [ind for ind in success_indicators if ind in page_content]
|
||||||
indicator in page_content for indicator in success_indicators
|
has_success_indicator = len(found_indicators) > 0
|
||||||
)
|
|
||||||
|
# Always take screenshot for debugging
|
||||||
|
screenshot_path = "/tmp/nc-php-app-auth-check.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
logger.info(f"Authorization check screenshot: {screenshot_path}")
|
||||||
|
logger.info(f"Found success indicators: {found_indicators}")
|
||||||
|
|
||||||
if not has_success_indicator:
|
if not has_success_indicator:
|
||||||
screenshot_path = "/tmp/nc-php-app-auth-check.png"
|
logger.error("Authorization check failed.")
|
||||||
await page.screenshot(path=screenshot_path)
|
|
||||||
logger.error(f"Authorization check failed. Screenshot: {screenshot_path}")
|
|
||||||
|
|
||||||
assert has_success_indicator, "Settings page should show user is authorized"
|
assert has_success_indicator, "Settings page should show user is authorized"
|
||||||
logger.info("✓ Authorization verification passed")
|
logger.info("✓ Authorization verification passed")
|
||||||
@@ -232,7 +249,7 @@ class TestNcPhpAppOAuth:
|
|||||||
page = authorized_nc_session["page"]
|
page = authorized_nc_session["page"]
|
||||||
host = authorized_nc_session["host"]
|
host = authorized_nc_session["host"]
|
||||||
|
|
||||||
await page.goto(f"{host}/settings/user/mcp")
|
await page.goto(f"{host}/settings/user/astrolabe")
|
||||||
await page.wait_for_load_state("networkidle")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
@@ -243,12 +260,12 @@ class TestNcPhpAppOAuth:
|
|||||||
logger.info(f"Screenshot saved: {screenshot_path}")
|
logger.info(f"Screenshot saved: {screenshot_path}")
|
||||||
logger.info(f"Page content excerpt: {page_content[:1000]}")
|
logger.info(f"Page content excerpt: {page_content[:1000]}")
|
||||||
|
|
||||||
# Verify session information is visible - these are the actual labels from template
|
# Verify session information is visible (Vue 3 personal.php template)
|
||||||
session_indicators = [
|
session_indicators = [
|
||||||
"Server Connection",
|
"Service Status",
|
||||||
"Session Information",
|
"Service URL",
|
||||||
"Connection Management",
|
"Version",
|
||||||
"MCP Server",
|
"Background Sync Access",
|
||||||
]
|
]
|
||||||
|
|
||||||
found_indicators = [ind for ind in session_indicators if ind in page_content]
|
found_indicators = [ind for ind in session_indicators if ind in page_content]
|
||||||
@@ -270,17 +287,17 @@ class TestNcPhpAppOAuth:
|
|||||||
host = authorized_nc_session["host"]
|
host = authorized_nc_session["host"]
|
||||||
|
|
||||||
# Check personal settings page shows server status
|
# Check personal settings page shows server status
|
||||||
await page.goto(f"{host}/settings/user/mcp")
|
await page.goto(f"{host}/settings/user/astrolabe")
|
||||||
await page.wait_for_load_state("networkidle")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
|
|
||||||
# Look for data that comes from management API or template structure
|
# Look for data that comes from management API or template structure (Vue 3)
|
||||||
api_indicators = [
|
api_indicators = [
|
||||||
"Server Connection", # Section header
|
"Service Status", # Section header
|
||||||
"Server URL", # Server info
|
"Service URL", # Server info from API
|
||||||
"Connection Management", # Connection section
|
"Version", # Server version from management API
|
||||||
"Vector Visualization", # Vector sync section
|
"Semantic Search", # Vector sync status
|
||||||
]
|
]
|
||||||
|
|
||||||
found_api_data = [ind for ind in api_indicators if ind in page_content]
|
found_api_data = [ind for ind in api_indicators if ind in page_content]
|
||||||
@@ -298,23 +315,24 @@ class TestNcPhpAppOAuth:
|
|||||||
page = authorized_nc_session["page"]
|
page = authorized_nc_session["page"]
|
||||||
host = authorized_nc_session["host"]
|
host = authorized_nc_session["host"]
|
||||||
|
|
||||||
await page.goto(f"{host}/settings/admin/mcp")
|
await page.goto(f"{host}/settings/admin/astrolabe")
|
||||||
await page.wait_for_load_state("networkidle")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
|
|
||||||
# Admin page should show server status
|
# Admin page should show server status (Vue 3 AdminSettings.vue)
|
||||||
admin_indicators = [
|
admin_indicators = [
|
||||||
"MCP Server",
|
"Astrolabe",
|
||||||
"Server Status",
|
"Service Status",
|
||||||
"Version",
|
"Version",
|
||||||
|
"Semantic Search",
|
||||||
]
|
]
|
||||||
|
|
||||||
found_indicators = [ind for ind in admin_indicators if ind in page_content]
|
found_indicators = [ind for ind in admin_indicators if ind in page_content]
|
||||||
|
|
||||||
# Admin page should at least show the MCP Server header
|
# Admin page should at least show the Astrolabe header or Service Status
|
||||||
assert "MCP Server" in page_content or "mcp" in page_content.lower(), (
|
assert "Astrolabe" in page_content or "Service Status" in page_content, (
|
||||||
"Admin settings page should show MCP Server section"
|
"Admin settings page should show Astrolabe section"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"✓ Admin settings page verified - found: {found_indicators}")
|
logger.info(f"✓ Admin settings page verified - found: {found_indicators}")
|
||||||
@@ -355,13 +373,13 @@ class TestNcPhpAppDisconnect:
|
|||||||
await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000)
|
await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000)
|
||||||
|
|
||||||
# Navigate to personal settings
|
# Navigate to personal settings
|
||||||
await page.goto(f"{host}/settings/user/mcp")
|
await page.goto(f"{host}/settings/user/astrolabe")
|
||||||
await page.wait_for_load_state("networkidle")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
|
|
||||||
# Check if user is authorized
|
# Check if user is authorized (Vue 3 personal.php shows Disconnect/Revoke when authorized)
|
||||||
if "Disconnect" not in page_content:
|
if "Disconnect" not in page_content and "Revoke Access" not in page_content:
|
||||||
pytest.skip("User not authorized - cannot test disconnect")
|
pytest.skip("User not authorized - cannot test disconnect")
|
||||||
|
|
||||||
# Click disconnect button
|
# Click disconnect button
|
||||||
@@ -384,10 +402,10 @@ class TestNcPhpAppDisconnect:
|
|||||||
# Wait for page reload
|
# Wait for page reload
|
||||||
await page.wait_for_load_state("networkidle")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
# Verify we're back to "Authorize Access" state
|
# Verify we're back to "Enable Semantic Search" state (Vue 3 oauth-required.php)
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
assert "Authorize" in page_content, (
|
assert "Enable Semantic Search" in page_content, (
|
||||||
"Settings page should show 'Authorize Access' after disconnect"
|
"Settings page should show 'Enable Semantic Search' after disconnect"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("✓ Disconnect flow test passed")
|
logger.info("✓ Disconnect flow test passed")
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ Note: Tests use JWT OAuth tokens because scopes are embedded in the token payloa
|
|||||||
enabling efficient scope-based tool filtering without additional API calls.
|
enabling efficient scope-based tool filtering without additional API calls.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_prm_endpoint():
|
async def test_prm_endpoint():
|
||||||
"""Test that the Protected Resource Metadata endpoint returns correct data."""
|
"""Test that the Protected Resource Metadata endpoint returns correct data."""
|
||||||
import httpx
|
|
||||||
|
|
||||||
# Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
|
# Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
@@ -60,7 +62,6 @@ async def test_basicauth_shows_all_tools(nc_mcp_client):
|
|||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
|
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
|
||||||
"""Test that a token with only read scopes filters out write tools."""
|
"""Test that a token with only read scopes filters out write tools."""
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -109,7 +110,6 @@ async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only
|
|||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
|
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
|
||||||
"""Test that a token with only write scopes filters out read tools."""
|
"""Test that a token with only write scopes filters out read tools."""
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -158,7 +158,6 @@ async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_onl
|
|||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
|
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
|
||||||
"""Test that a token with both read and write scopes scopes can see all tools."""
|
"""Test that a token with both read and write scopes scopes can see all tools."""
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -402,7 +401,6 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
|||||||
- OAuth provisioning tools (requiring only 'openid') remain visible
|
- OAuth provisioning tools (requiring only 'openid') remain visible
|
||||||
so users can provision Nextcloud access after authentication
|
so users can provision Nextcloud access after authentication
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -442,7 +440,6 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
|
|||||||
Simulates user granting only read permission during OAuth consent.
|
Simulates user granting only read permission during OAuth consent.
|
||||||
Expected: Should see read tools but not write tools.
|
Expected: Should see read tools but not write tools.
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -480,7 +477,6 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
|
|||||||
Simulates user granting only write permission during OAuth consent.
|
Simulates user granting only write permission during OAuth consent.
|
||||||
Expected: Should see write tools but not read-only tools.
|
Expected: Should see write tools but not read-only tools.
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -518,7 +514,6 @@ async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access
|
|||||||
Simulates user granting both permissions during OAuth consent.
|
Simulates user granting both permissions during OAuth consent.
|
||||||
Expected: Should see all 90+ tools (both read and write).
|
Expected: Should see all 90+ tools (both read and write).
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ Tests the critical token exchange pattern that separates:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
@@ -21,9 +23,6 @@ pytestmark = pytest.mark.unit
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def token_storage():
|
async def token_storage():
|
||||||
"""Create test token storage."""
|
"""Create test token storage."""
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
|
|
||||||
# Generate valid Fernet key
|
# Generate valid Fernet key
|
||||||
encryption_key = Fernet.generate_key()
|
encryption_key = Fernet.generate_key()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Integration tests for Calendar VTODO (task) MCP tools."""
|
"""Integration tests for Calendar VTODO (task) MCP tools."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@@ -41,7 +42,6 @@ async def test_mcp_todo_complete_workflow(
|
|||||||
|
|
||||||
# Extract UID from the result
|
# Extract UID from the result
|
||||||
result_data = create_result.content[0].text
|
result_data = create_result.content[0].text
|
||||||
import json
|
|
||||||
|
|
||||||
result_json = json.loads(result_data)
|
result_json = json.loads(result_data)
|
||||||
todo_uid = result_json["uid"]
|
todo_uid = result_json["uid"]
|
||||||
@@ -156,7 +156,6 @@ async def test_mcp_list_todos_with_filters(
|
|||||||
{"calendar_name": calendar_name, "status": "NEEDS-ACTION"},
|
{"calendar_name": calendar_name, "status": "NEEDS-ACTION"},
|
||||||
)
|
)
|
||||||
assert result.isError is False
|
assert result.isError is False
|
||||||
import json
|
|
||||||
|
|
||||||
data = json.loads(result.content[0].text)
|
data = json.loads(result.content[0].text)
|
||||||
needs_action_todos = [t for t in data["todos"] if t["uid"] in created_uids]
|
needs_action_todos = [t for t in data["todos"] if t["uid"] in created_uids]
|
||||||
@@ -253,8 +252,6 @@ async def test_mcp_search_todos_across_calendars(
|
|||||||
)
|
)
|
||||||
assert search_result.isError is False
|
assert search_result.isError is False
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
data = json.loads(search_result.content[0].text)
|
data = json.loads(search_result.content[0].text)
|
||||||
assert "todos" in data
|
assert "todos" in data
|
||||||
|
|
||||||
@@ -388,8 +385,6 @@ async def test_mcp_todo_with_dates(
|
|||||||
)
|
)
|
||||||
assert create_result.isError is False
|
assert create_result.isError is False
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
result_data = json.loads(create_result.content[0].text)
|
result_data = json.loads(create_result.content[0].text)
|
||||||
todo_uid = result_data["uid"]
|
todo_uid = result_data["uid"]
|
||||||
|
|
||||||
@@ -432,8 +427,6 @@ async def test_mcp_todo_categories(
|
|||||||
)
|
)
|
||||||
assert create_result.isError is False
|
assert create_result.isError is False
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
result_data = json.loads(create_result.content[0].text)
|
result_data = json.loads(create_result.content[0].text)
|
||||||
todo_uid = result_data["uid"]
|
todo_uid = result_data["uid"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for configuration validation."""
|
"""Tests for configuration validation."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -48,7 +49,6 @@ class TestQdrantConfigValidation:
|
|||||||
|
|
||||||
def test_api_key_warning_in_local_mode(self, caplog):
|
def test_api_key_warning_in_local_mode(self, caplog):
|
||||||
"""Test that API key in local mode triggers warning."""
|
"""Test that API key in local mode triggers warning."""
|
||||||
import logging
|
|
||||||
|
|
||||||
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||||
Settings(
|
Settings(
|
||||||
@@ -59,7 +59,6 @@ class TestQdrantConfigValidation:
|
|||||||
|
|
||||||
def test_api_key_no_warning_in_network_mode(self, caplog):
|
def test_api_key_no_warning_in_network_mode(self, caplog):
|
||||||
"""Test that API key in network mode doesn't trigger warning."""
|
"""Test that API key in network mode doesn't trigger warning."""
|
||||||
import logging
|
|
||||||
|
|
||||||
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||||
Settings(
|
Settings(
|
||||||
@@ -206,7 +205,6 @@ class TestChunkConfigValidation:
|
|||||||
|
|
||||||
def test_small_chunk_size_warning(self, caplog):
|
def test_small_chunk_size_warning(self, caplog):
|
||||||
"""Test that chunk size < 512 triggers warning."""
|
"""Test that chunk size < 512 triggers warning."""
|
||||||
import logging
|
|
||||||
|
|
||||||
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||||
Settings(
|
Settings(
|
||||||
@@ -221,7 +219,6 @@ class TestChunkConfigValidation:
|
|||||||
|
|
||||||
def test_reasonable_chunk_size_no_warning(self, caplog):
|
def test_reasonable_chunk_size_no_warning(self, caplog):
|
||||||
"""Test that chunk size >= 512 doesn't trigger warning."""
|
"""Test that chunk size >= 512 doesn't trigger warning."""
|
||||||
import logging
|
|
||||||
|
|
||||||
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||||
Settings(
|
Settings(
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ class TestMultiUserBasicValidation:
|
|||||||
assert any("nextcloud_password" in err.lower() for err in errors)
|
assert any("nextcloud_password" in err.lower() for err in errors)
|
||||||
|
|
||||||
def test_offline_access_missing_oauth_credentials(self):
|
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(
|
settings = Settings(
|
||||||
nextcloud_host="http://localhost",
|
nextcloud_host="http://localhost",
|
||||||
enable_multi_user_basic_auth=True,
|
enable_multi_user_basic_auth=True,
|
||||||
@@ -293,7 +293,8 @@ class TestMultiUserBasicValidation:
|
|||||||
mode, errors = validate_configuration(settings)
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
assert mode == AuthMode.MULTI_USER_BASIC
|
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):
|
def test_offline_access_missing_encryption_key(self):
|
||||||
"""Test error when offline access enabled but encryption key missing."""
|
"""Test error when offline access enabled but encryption key missing."""
|
||||||
@@ -311,20 +312,35 @@ class TestMultiUserBasicValidation:
|
|||||||
assert mode == AuthMode.MULTI_USER_BASIC
|
assert mode == AuthMode.MULTI_USER_BASIC
|
||||||
assert any("token_encryption_key" in err.lower() for err in errors)
|
assert any("token_encryption_key" in err.lower() for err in errors)
|
||||||
|
|
||||||
def test_vector_sync_requires_offline_access(self):
|
def test_vector_sync_auto_enables_background_ops_in_multi_user_mode(self):
|
||||||
"""Test error when vector sync enabled but offline access disabled."""
|
"""Test vector sync automatically enables background operations in multi-user mode (ADR-021)."""
|
||||||
settings = Settings(
|
# Before ADR-021: This would have failed validation (required explicit ENABLE_OFFLINE_ACCESS)
|
||||||
nextcloud_host="http://localhost",
|
# After ADR-021: vector_sync_enabled auto-enables background operations
|
||||||
enable_multi_user_basic_auth=True,
|
with patch.dict(
|
||||||
vector_sync_enabled=True,
|
os.environ,
|
||||||
qdrant_location=":memory:",
|
{
|
||||||
ollama_base_url="http://ollama:11434",
|
"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 mode == AuthMode.MULTI_USER_BASIC
|
||||||
assert any("enable_offline_access" in err.lower() for err in errors)
|
# 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:
|
class TestOAuthSingleAudienceValidation:
|
||||||
@@ -396,19 +412,33 @@ class TestOAuthSingleAudienceValidation:
|
|||||||
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
assert any("token_encryption_key" in err.lower() for err in errors)
|
assert any("token_encryption_key" in err.lower() for err in errors)
|
||||||
|
|
||||||
def test_vector_sync_requires_offline_access(self):
|
def test_vector_sync_auto_enables_background_ops_in_oauth_mode(self):
|
||||||
"""Test error when vector sync enabled but offline access disabled."""
|
"""Test vector sync automatically enables background operations in OAuth mode (ADR-021)."""
|
||||||
settings = Settings(
|
# Before ADR-021: This would have failed validation (required explicit ENABLE_OFFLINE_ACCESS)
|
||||||
nextcloud_host="http://localhost",
|
# After ADR-021: vector_sync_enabled auto-enables background operations in multi-user modes
|
||||||
vector_sync_enabled=True,
|
with patch.dict(
|
||||||
qdrant_location=":memory:",
|
os.environ,
|
||||||
ollama_base_url="http://ollama:11434",
|
{
|
||||||
)
|
"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 mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
assert any("enable_offline_access" in err.lower() for err in errors)
|
# 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:
|
class TestOAuthTokenExchangeValidation:
|
||||||
@@ -576,3 +606,387 @@ class TestEdgeCases:
|
|||||||
|
|
||||||
# Should have errors for missing host (OAuth mode is default)
|
# Should have errors for missing host (OAuth mode is default)
|
||||||
assert len(errors) > 0
|
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
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for hybrid authentication mode OAuth setup.
|
||||||
|
|
||||||
|
Tests the setup_oauth_config_for_multi_user_basic() function that enables
|
||||||
|
hybrid authentication where MCP operations use BasicAuth and management
|
||||||
|
APIs use OAuth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.app import setup_oauth_config_for_multi_user_basic
|
||||||
|
from nextcloud_mcp_server.config import Settings
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def hybrid_auth_settings():
|
||||||
|
"""Create settings for hybrid auth mode testing."""
|
||||||
|
return Settings(
|
||||||
|
nextcloud_host="https://nextcloud.example.com",
|
||||||
|
enable_offline_access=False, # Start with offline access disabled
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def oidc_discovery_response():
|
||||||
|
"""Mock OIDC discovery endpoint response."""
|
||||||
|
return {
|
||||||
|
"issuer": "https://nextcloud.example.com",
|
||||||
|
"authorization_endpoint": "https://nextcloud.example.com/apps/oidc/authorize",
|
||||||
|
"token_endpoint": "https://nextcloud.example.com/apps/oidc/token",
|
||||||
|
"userinfo_endpoint": "https://nextcloud.example.com/apps/oidc/userinfo",
|
||||||
|
"jwks_uri": "https://nextcloud.example.com/apps/oidc/jwks",
|
||||||
|
"introspection_endpoint": "https://nextcloud.example.com/apps/oidc/introspect",
|
||||||
|
"registration_endpoint": "https://nextcloud.example.com/apps/oidc/register",
|
||||||
|
"scopes_supported": ["openid", "profile", "email", "offline_access"],
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||||
|
"subject_types_supported": ["public"],
|
||||||
|
"id_token_signing_alg_values_supported": ["RS256"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetupOAuthConfigForMultiUserBasic:
|
||||||
|
"""Test setup_oauth_config_for_multi_user_basic() function."""
|
||||||
|
|
||||||
|
async def test_successful_setup_without_offline_access(
|
||||||
|
self, hybrid_auth_settings, oidc_discovery_response, mocker
|
||||||
|
):
|
||||||
|
"""Test successful OAuth setup without offline access."""
|
||||||
|
# Mock httpx.AsyncClient
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json = MagicMock(return_value=oidc_discovery_response)
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch("httpx.AsyncClient", return_value=mock_client)
|
||||||
|
|
||||||
|
# Call function
|
||||||
|
(
|
||||||
|
verifier,
|
||||||
|
storage,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
) = await setup_oauth_config_for_multi_user_basic(
|
||||||
|
settings=hybrid_auth_settings,
|
||||||
|
client_id="test-client-id",
|
||||||
|
client_secret="test-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify OIDC discovery was called
|
||||||
|
mock_client.get.assert_called_once_with(
|
||||||
|
"https://nextcloud.example.com/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify settings were updated
|
||||||
|
assert hybrid_auth_settings.oidc_client_id == "test-client-id"
|
||||||
|
assert hybrid_auth_settings.oidc_client_secret == "test-client-secret"
|
||||||
|
assert hybrid_auth_settings.oidc_issuer == "https://nextcloud.example.com"
|
||||||
|
assert (
|
||||||
|
hybrid_auth_settings.jwks_uri
|
||||||
|
== "https://nextcloud.example.com/apps/oidc/jwks"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
hybrid_auth_settings.introspection_uri
|
||||||
|
== "https://nextcloud.example.com/apps/oidc/introspect"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
hybrid_auth_settings.userinfo_uri
|
||||||
|
== "https://nextcloud.example.com/apps/oidc/userinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify token verifier was created
|
||||||
|
assert verifier is not None
|
||||||
|
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||||
|
|
||||||
|
assert isinstance(verifier, UnifiedTokenVerifier)
|
||||||
|
|
||||||
|
# Verify storage is None (offline access disabled)
|
||||||
|
assert storage is None
|
||||||
|
|
||||||
|
# Verify credentials returned
|
||||||
|
assert client_id == "test-client-id"
|
||||||
|
assert client_secret == "test-client-secret"
|
||||||
|
|
||||||
|
async def test_successful_setup_with_offline_access(
|
||||||
|
self, hybrid_auth_settings, oidc_discovery_response, mocker
|
||||||
|
):
|
||||||
|
"""Test successful OAuth setup with offline access enabled."""
|
||||||
|
# Enable offline access
|
||||||
|
hybrid_auth_settings.enable_offline_access = True
|
||||||
|
|
||||||
|
# Generate a valid Fernet key for testing
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
valid_fernet_key = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
# Mock TOKEN_ENCRYPTION_KEY environment variable
|
||||||
|
mocker.patch(
|
||||||
|
"os.getenv",
|
||||||
|
side_effect=lambda k, default=None: {
|
||||||
|
"TOKEN_ENCRYPTION_KEY": valid_fernet_key,
|
||||||
|
"NEXTCLOUD_MCP_SERVER_URL": "http://localhost:8000",
|
||||||
|
}.get(k, default),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock httpx.AsyncClient
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json = MagicMock(return_value=oidc_discovery_response)
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch("httpx.AsyncClient", return_value=mock_client)
|
||||||
|
|
||||||
|
# Call function
|
||||||
|
(
|
||||||
|
verifier,
|
||||||
|
storage,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
) = await setup_oauth_config_for_multi_user_basic(
|
||||||
|
settings=hybrid_auth_settings,
|
||||||
|
client_id="test-client-id",
|
||||||
|
client_secret="test-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify storage was created
|
||||||
|
assert storage is not None
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
assert isinstance(storage, RefreshTokenStorage)
|
||||||
|
|
||||||
|
async def test_discovered_urls_used_directly(
|
||||||
|
self, hybrid_auth_settings, oidc_discovery_response, mocker
|
||||||
|
):
|
||||||
|
"""Test that discovered URLs are used directly without rewriting."""
|
||||||
|
# Mock httpx.AsyncClient
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json = MagicMock(return_value=oidc_discovery_response)
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch("httpx.AsyncClient", return_value=mock_client)
|
||||||
|
|
||||||
|
# Call function
|
||||||
|
(
|
||||||
|
verifier,
|
||||||
|
storage,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
) = await setup_oauth_config_for_multi_user_basic(
|
||||||
|
settings=hybrid_auth_settings,
|
||||||
|
client_id="test-client-id",
|
||||||
|
client_secret="test-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify discovered URLs are used directly (not rewritten)
|
||||||
|
assert hybrid_auth_settings.jwks_uri == oidc_discovery_response["jwks_uri"]
|
||||||
|
assert (
|
||||||
|
hybrid_auth_settings.introspection_uri
|
||||||
|
== oidc_discovery_response["introspection_endpoint"]
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
hybrid_auth_settings.userinfo_uri
|
||||||
|
== oidc_discovery_response["userinfo_endpoint"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify issuer is used directly for JWT validation
|
||||||
|
assert hybrid_auth_settings.oidc_issuer == oidc_discovery_response["issuer"]
|
||||||
|
|
||||||
|
async def test_oidc_discovery_failure_http_error(
|
||||||
|
self, hybrid_auth_settings, mocker
|
||||||
|
):
|
||||||
|
"""Test handling of OIDC discovery HTTP errors."""
|
||||||
|
|
||||||
|
# Create a mock response with a status error
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 404
|
||||||
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Not Found",
|
||||||
|
request=MagicMock(),
|
||||||
|
response=MagicMock(status_code=404),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
# Return None to propagate exceptions (not suppress them)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mocker.patch("httpx.AsyncClient", return_value=mock_client)
|
||||||
|
|
||||||
|
# Should raise ValueError with helpful message (not UnboundLocalError)
|
||||||
|
with pytest.raises(ValueError, match="OIDC discovery failed"):
|
||||||
|
await setup_oauth_config_for_multi_user_basic(
|
||||||
|
settings=hybrid_auth_settings,
|
||||||
|
client_id="test-client-id",
|
||||||
|
client_secret="test-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_oidc_discovery_failure_connection_error(
|
||||||
|
self, hybrid_auth_settings, mocker
|
||||||
|
):
|
||||||
|
"""Test handling of OIDC discovery connection errors."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(
|
||||||
|
side_effect=httpx.ConnectError("Connection refused")
|
||||||
|
)
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
# Return None to propagate exceptions (not suppress them)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mocker.patch("httpx.AsyncClient", return_value=mock_client)
|
||||||
|
|
||||||
|
# Should raise ValueError with helpful message
|
||||||
|
with pytest.raises(ValueError, match="Cannot connect to"):
|
||||||
|
await setup_oauth_config_for_multi_user_basic(
|
||||||
|
settings=hybrid_auth_settings,
|
||||||
|
client_id="test-client-id",
|
||||||
|
client_secret="test-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_missing_nextcloud_host(self):
|
||||||
|
"""Test that missing NEXTCLOUD_HOST raises ValueError."""
|
||||||
|
settings = Settings() # No nextcloud_host set
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="NEXTCLOUD_HOST is required"):
|
||||||
|
await setup_oauth_config_for_multi_user_basic(
|
||||||
|
settings=settings,
|
||||||
|
client_id="test-client-id",
|
||||||
|
client_secret="test-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_custom_discovery_url(
|
||||||
|
self, hybrid_auth_settings, oidc_discovery_response, mocker
|
||||||
|
):
|
||||||
|
"""Test using custom OIDC discovery URL."""
|
||||||
|
# Mock OIDC_DISCOVERY_URL environment variable
|
||||||
|
custom_discovery_url = (
|
||||||
|
"https://custom.idp.example.com/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
"os.getenv",
|
||||||
|
side_effect=lambda k, default=None: {
|
||||||
|
"OIDC_DISCOVERY_URL": custom_discovery_url,
|
||||||
|
"NEXTCLOUD_MCP_SERVER_URL": "http://localhost:8000",
|
||||||
|
}.get(k, default),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock httpx.AsyncClient
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json = MagicMock(return_value=oidc_discovery_response)
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch("httpx.AsyncClient", return_value=mock_client)
|
||||||
|
|
||||||
|
# Call function
|
||||||
|
await setup_oauth_config_for_multi_user_basic(
|
||||||
|
settings=hybrid_auth_settings,
|
||||||
|
client_id="test-client-id",
|
||||||
|
client_secret="test-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify custom discovery URL was used
|
||||||
|
mock_client.get.assert_called_once_with(custom_discovery_url)
|
||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.4.4"
|
version = "0.7.2"
|
||||||
tag_format = "astrolabe-v$version"
|
tag_format = "astrolabe-v$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
|
|||||||
Vendored
+66
@@ -25,6 +25,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Requires external MCP server deployment
|
- Requires external MCP server deployment
|
||||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||||
|
|
||||||
|
## astrolabe-v0.7.2 (2025-12-30)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||||
|
|
||||||
|
## astrolabe-v0.7.1 (2025-12-30)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||||
|
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
||||||
|
- **mcp**: Move all imports to the top of modules
|
||||||
|
|
||||||
|
## astrolabe-v0.7.0 (2025-12-26)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Remove URL rewriting in favor of proper nextcloud config
|
||||||
|
- **helm**: migrate to new environment variable naming convention
|
||||||
|
- Migrate to vue 3
|
||||||
|
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
|
||||||
|
- **helm**: add support for multi-user BasicAuth mode
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
|
||||||
|
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
|
||||||
|
- **auth**: Skip issuer validation for management API tokens
|
||||||
|
- Use settings.enable_offline_access for env var consolidation
|
||||||
|
- Add required config.py attributes
|
||||||
|
- **docker**: remove overwritehost to fix container-to-container DCR
|
||||||
|
- **deps**: update dependency @nextcloud/vue to v9
|
||||||
|
- **deps**: update dependency vue to v3
|
||||||
|
- **helm**: set OIDC client env vars when using existingSecret
|
||||||
|
- **helm**: trigger chart release workflow on helm chart tags
|
||||||
|
- **helm**: address PR #447 reviewer feedback
|
||||||
|
- **helm**: include MCP server version bumps in changelog pattern
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **auth**: Decouple BasicAuth and OAuth authentication strategies
|
||||||
|
|
||||||
|
## astrolabe-v0.6.0 (2025-12-22)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **config**: enable DCR for multi-user BasicAuth with offline access
|
||||||
|
- **astrolabe**: implement app password provisioning for multi-user background sync
|
||||||
|
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
|
||||||
|
|
||||||
|
## astrolabe-v0.5.0 (2025-12-20)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **auth**: add multi-user BasicAuth pass-through mode
|
||||||
|
- **astrolabe**: add dynamic MCP server configuration for testing
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **config**: address reviewer feedback
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **config**: centralize configuration validation and simplify startup
|
||||||
|
|
||||||
## astrolabe-v0.4.4 (2025-12-20)
|
## astrolabe-v0.4.4 (2025-12-20)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
|
|||||||
|
|
||||||
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
||||||
]]></description>
|
]]></description>
|
||||||
<version>0.4.4</version>
|
<version>0.7.2</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||||
<namespace>Astrolabe</namespace>
|
<namespace>Astrolabe</namespace>
|
||||||
|
|||||||
+32
@@ -34,6 +34,28 @@ return [
|
|||||||
'verb' => 'POST',
|
'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/revoke',
|
||||||
|
'verb' => 'POST',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'credentials#getStatus',
|
||||||
|
'url' => '/api/v1/background-sync/status',
|
||||||
|
'verb' => 'GET',
|
||||||
|
],
|
||||||
|
|
||||||
// Vector search API routes
|
// Vector search API routes
|
||||||
[
|
[
|
||||||
'name' => 'api#search',
|
'name' => 'api#search',
|
||||||
@@ -52,6 +74,16 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Admin settings routes
|
// Admin settings routes
|
||||||
|
[
|
||||||
|
'name' => 'api#serverStatus',
|
||||||
|
'url' => '/api/admin/server-status',
|
||||||
|
'verb' => 'GET',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'api#adminVectorStatus',
|
||||||
|
'url' => '/api/admin/vector-status',
|
||||||
|
'verb' => 'GET',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'name' => 'api#saveSearchSettings',
|
'name' => 'api#saveSearchSettings',
|
||||||
'url' => '/api/admin/search-settings',
|
'url' => '/api/admin/search-settings',
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace OCA\Astrolabe\AppInfo;
|
namespace OCA\Astrolabe\AppInfo;
|
||||||
|
|
||||||
|
use OCA\Astrolabe\Listener\AstrolabeAdminSettingsListener;
|
||||||
use OCA\Astrolabe\Search\SemanticSearchProvider;
|
use OCA\Astrolabe\Search\SemanticSearchProvider;
|
||||||
|
use OCA\Astrolabe\Settings\AstrolabeAdminSettings;
|
||||||
use OCP\AppFramework\App;
|
use OCP\AppFramework\App;
|
||||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||||
|
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
|
||||||
|
use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
|
||||||
|
|
||||||
class Application extends App implements IBootstrap {
|
class Application extends App implements IBootstrap {
|
||||||
public const APP_ID = 'astrolabe';
|
public const APP_ID = 'astrolabe';
|
||||||
@@ -21,6 +25,19 @@ class Application extends App implements IBootstrap {
|
|||||||
public function register(IRegistrationContext $context): void {
|
public function register(IRegistrationContext $context): void {
|
||||||
// Register unified search provider for semantic search
|
// Register unified search provider for semantic search
|
||||||
$context->registerSearchProvider(SemanticSearchProvider::class);
|
$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 {
|
public function boot(IBootContext $context): void {
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ class ApiController extends Controller {
|
|||||||
// TODO: Add flash message/notification for user feedback
|
// TODO: Add flash message/notification for user feedback
|
||||||
} else {
|
} else {
|
||||||
$this->logger->info("Successfully revoked background access for user $userId");
|
$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
|
// TODO: Add success flash message/notification
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +254,70 @@ class ApiController extends Controller {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MCP server status.
|
||||||
|
*
|
||||||
|
* Admin-only endpoint for admin settings page.
|
||||||
|
* Returns server version, uptime, and vector sync availability.
|
||||||
|
*
|
||||||
|
* @return JSONResponse
|
||||||
|
*/
|
||||||
|
public function serverStatus(): JSONResponse {
|
||||||
|
$status = $this->client->getStatus();
|
||||||
|
|
||||||
|
// Validate that status is an array before accessing
|
||||||
|
if (!is_array($status)) {
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid response from MCP server'
|
||||||
|
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($status['error'])) {
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $status['error']
|
||||||
|
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'status' => $status
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get vector sync status for admin.
|
||||||
|
*
|
||||||
|
* Admin-only endpoint for admin settings page.
|
||||||
|
* Returns indexing metrics and sync status.
|
||||||
|
*
|
||||||
|
* @return JSONResponse
|
||||||
|
*/
|
||||||
|
public function adminVectorStatus(): JSONResponse {
|
||||||
|
$status = $this->client->getVectorSyncStatus();
|
||||||
|
|
||||||
|
// Validate that status is an array before accessing
|
||||||
|
if (!is_array($status)) {
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid response from MCP server'
|
||||||
|
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($status['error'])) {
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $status['error']
|
||||||
|
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'status' => $status
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save admin search settings.
|
* Save admin search settings.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\Astrolabe\Controller;
|
||||||
|
|
||||||
|
use OCA\Astrolabe\Service\McpServerClient;
|
||||||
|
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||||
|
use OCP\AppFramework\Controller;
|
||||||
|
use OCP\AppFramework\Http;
|
||||||
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||||
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
|
use OCP\Http\Client\IClientService;
|
||||||
|
use OCP\IConfig;
|
||||||
|
use OCP\IRequest;
|
||||||
|
use OCP\IURLGenerator;
|
||||||
|
use OCP\IUserSession;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for managing background sync credentials (app passwords).
|
||||||
|
*
|
||||||
|
* Handles storing and validating app passwords for multi-user BasicAuth mode.
|
||||||
|
*/
|
||||||
|
class CredentialsController extends Controller {
|
||||||
|
private $tokenStorage;
|
||||||
|
private $userSession;
|
||||||
|
private $logger;
|
||||||
|
private $config;
|
||||||
|
private $client;
|
||||||
|
private $httpClientService;
|
||||||
|
private $urlGenerator;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $appName,
|
||||||
|
IRequest $request,
|
||||||
|
McpTokenStorage $tokenStorage,
|
||||||
|
IUserSession $userSession,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
IConfig $config,
|
||||||
|
McpServerClient $client,
|
||||||
|
IClientService $httpClientService,
|
||||||
|
IURLGenerator $urlGenerator,
|
||||||
|
) {
|
||||||
|
parent::__construct($appName, $request);
|
||||||
|
$this->tokenStorage = $tokenStorage;
|
||||||
|
$this->userSession = $userSession;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->config = $config;
|
||||||
|
$this->client = $client;
|
||||||
|
$this->httpClientService = $httpClientService;
|
||||||
|
$this->urlGenerator = $urlGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store app password for background sync.
|
||||||
|
*
|
||||||
|
* Validates the app password by making a test request to Nextcloud,
|
||||||
|
* then stores it encrypted if valid.
|
||||||
|
*
|
||||||
|
* @param string $appPassword Nextcloud app password
|
||||||
|
* @return JSONResponse
|
||||||
|
*/
|
||||||
|
#[NoAdminRequired]
|
||||||
|
public function storeAppPassword(string $appPassword): JSONResponse {
|
||||||
|
$user = $this->userSession->getUser();
|
||||||
|
if (!$user) {
|
||||||
|
$this->logger->error('storeAppPassword called without authenticated user');
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'User not authenticated'
|
||||||
|
], Http::STATUS_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $user->getUID();
|
||||||
|
|
||||||
|
// Validate app password format (xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$/', $appPassword)) {
|
||||||
|
$this->logger->warning("Invalid app password format for user: $userId");
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid app password format'
|
||||||
|
], Http::STATUS_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate app password with Nextcloud
|
||||||
|
$isValid = $this->validateAppPassword($userId, $appPassword);
|
||||||
|
|
||||||
|
if (!$isValid) {
|
||||||
|
$this->logger->warning("App password validation failed for user: $userId");
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid app password. Please check the password and try again.'
|
||||||
|
], Http::STATUS_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store encrypted app password
|
||||||
|
try {
|
||||||
|
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
|
||||||
|
$this->logger->info("Successfully stored app password for user: $userId");
|
||||||
|
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'App password saved successfully'
|
||||||
|
], Http::STATUS_OK);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error("Failed to store app password for user $userId", [
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Failed to save app password'
|
||||||
|
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate app password by making a test request to Nextcloud.
|
||||||
|
*
|
||||||
|
* @param string $userId User ID
|
||||||
|
* @param string $appPassword App password to validate
|
||||||
|
* @return bool True if valid, false otherwise
|
||||||
|
*/
|
||||||
|
private function validateAppPassword(string $userId, string $appPassword): bool {
|
||||||
|
try {
|
||||||
|
// Use 127.0.0.1 for internal validation (we're running inside Nextcloud container)
|
||||||
|
// Using IP address instead of 'localhost' to avoid Nextcloud's overwrite.cli.url rewriting
|
||||||
|
// getAbsoluteURL() returns the external URL which isn't accessible from inside the container
|
||||||
|
$baseUrl = 'http://127.0.0.1';
|
||||||
|
|
||||||
|
// Make a test request to Nextcloud API with BasicAuth
|
||||||
|
// Using OCS API user endpoint as a lightweight test
|
||||||
|
$testUrl = $baseUrl . '/ocs/v1.php/cloud/user?format=json';
|
||||||
|
|
||||||
|
$this->logger->debug("Validating app password for user: $userId against $testUrl");
|
||||||
|
|
||||||
|
// Use Nextcloud's HTTP client
|
||||||
|
$httpClient = $this->httpClientService->newClient();
|
||||||
|
|
||||||
|
$response = $httpClient->get($testUrl, [
|
||||||
|
'auth' => [$userId, $appPassword],
|
||||||
|
'headers' => [
|
||||||
|
'OCS-APIRequest' => 'true',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
],
|
||||||
|
'timeout' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$statusCode = $response->getStatusCode();
|
||||||
|
|
||||||
|
// Success is 200 OK
|
||||||
|
if ($statusCode === 200) {
|
||||||
|
$this->logger->debug("App password validation successful for user: $userId");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->warning("App password validation failed for user: $userId (HTTP $statusCode)");
|
||||||
|
return false;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error("Exception during app password validation for user $userId", [
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get background sync credentials status for the current user.
|
||||||
|
*
|
||||||
|
* @return JSONResponse
|
||||||
|
*/
|
||||||
|
#[NoAdminRequired]
|
||||||
|
public function getStatus(): JSONResponse {
|
||||||
|
$user = $this->userSession->getUser();
|
||||||
|
if (!$user) {
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'User not authenticated'
|
||||||
|
], Http::STATUS_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $user->getUID();
|
||||||
|
|
||||||
|
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||||
|
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||||
|
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||||
|
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'has_background_access' => $hasAccess,
|
||||||
|
'sync_type' => $syncType,
|
||||||
|
'provisioned_at' => $provisionedAt,
|
||||||
|
], Http::STATUS_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get credentials for a specific user (admin only).
|
||||||
|
*
|
||||||
|
* Note: This does NOT return the actual password, only metadata.
|
||||||
|
*
|
||||||
|
* @param string $userId User ID to check
|
||||||
|
* @return JSONResponse
|
||||||
|
*/
|
||||||
|
public function getCredentials(string $userId): JSONResponse {
|
||||||
|
// This endpoint should only be accessible by admins
|
||||||
|
// For now, just return metadata (not actual credentials)
|
||||||
|
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||||
|
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||||
|
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||||
|
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'has_background_access' => $hasAccess,
|
||||||
|
'sync_type' => $syncType,
|
||||||
|
'provisioned_at' => $provisionedAt,
|
||||||
|
], Http::STATUS_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete background sync credentials for the current user.
|
||||||
|
*
|
||||||
|
* @return JSONResponse
|
||||||
|
*/
|
||||||
|
#[NoAdminRequired]
|
||||||
|
public function deleteCredentials(): JSONResponse {
|
||||||
|
$user = $this->userSession->getUser();
|
||||||
|
if (!$user) {
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'User not authenticated'
|
||||||
|
], Http::STATUS_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $user->getUID();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete both OAuth tokens and app password (if any exist)
|
||||||
|
$this->tokenStorage->deleteUserToken($userId);
|
||||||
|
$this->tokenStorage->deleteBackgroundSyncPassword($userId);
|
||||||
|
|
||||||
|
$this->logger->info("Deleted background sync credentials for user: $userId");
|
||||||
|
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Credentials deleted successfully'
|
||||||
|
], Http::STATUS_OK);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error("Failed to delete credentials for user $userId", [
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Failed to delete credentials'
|
||||||
|
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-2
@@ -335,9 +335,10 @@ class OAuthController extends Controller {
|
|||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Fall back to Nextcloud's OIDC app
|
// Fall back to Nextcloud's OIDC app
|
||||||
// Use internal localhost URL for HTTP request (always accessible from inside container)
|
// Use internal localhost URL for HTTP request (accessible from inside container)
|
||||||
// The OIDC discovery response will contain proper external URLs based on overwrite.cli.url
|
// We'll transform the returned URLs to external format after discovery
|
||||||
$discoveryUrl = 'http://localhost/.well-known/openid-configuration';
|
$discoveryUrl = 'http://localhost/.well-known/openid-configuration';
|
||||||
|
$internalBaseUrl = 'http://localhost';
|
||||||
|
|
||||||
$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
|
$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
|
||||||
'discovery_url' => $discoveryUrl,
|
'discovery_url' => $discoveryUrl,
|
||||||
@@ -368,6 +369,16 @@ class OAuthController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$authEndpoint = $discovery['authorization_endpoint'];
|
$authEndpoint = $discovery['authorization_endpoint'];
|
||||||
|
|
||||||
|
// Transform internal URL to external URL if using Nextcloud OIDC app
|
||||||
|
// The discovery was done via internal http://localhost but browsers need
|
||||||
|
// the external URL (e.g., http://localhost:8080)
|
||||||
|
if (isset($internalBaseUrl)) {
|
||||||
|
$externalBaseUrl = $this->urlGenerator->getAbsoluteURL('/');
|
||||||
|
$externalBaseUrl = rtrim($externalBaseUrl, '/');
|
||||||
|
$authEndpoint = str_replace($internalBaseUrl, $externalBaseUrl, $authEndpoint);
|
||||||
|
}
|
||||||
|
|
||||||
$this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [
|
$this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [
|
||||||
'auth_endpoint' => $authEndpoint,
|
'auth_endpoint' => $authEndpoint,
|
||||||
'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',
|
'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\Astrolabe\Listener;
|
||||||
|
|
||||||
|
use OCA\Astrolabe\AppInfo\Application;
|
||||||
|
use OCP\EventDispatcher\Event;
|
||||||
|
use OCP\EventDispatcher\IEventListener;
|
||||||
|
use OCP\IConfig;
|
||||||
|
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
|
||||||
|
use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template-implements IEventListener<DeclarativeSettingsGetValueEvent|DeclarativeSettingsSetValueEvent>
|
||||||
|
*/
|
||||||
|
class AstrolabeAdminSettingsListener implements IEventListener {
|
||||||
|
public function __construct(
|
||||||
|
private IConfig $config,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Event $event): void {
|
||||||
|
if (!$event instanceof DeclarativeSettingsGetValueEvent && !$event instanceof DeclarativeSettingsSetValueEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event->getApp() !== Application::APP_ID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event->getFormId() !== 'astrolabe-admin-settings') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event instanceof DeclarativeSettingsGetValueEvent) {
|
||||||
|
$this->handleGetValue($event);
|
||||||
|
} elseif ($event instanceof DeclarativeSettingsSetValueEvent) {
|
||||||
|
$this->handleSetValue($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleGetValue(DeclarativeSettingsGetValueEvent $event): void {
|
||||||
|
$fieldId = $event->getFieldId();
|
||||||
|
|
||||||
|
// Map field IDs to system config keys
|
||||||
|
$value = match($fieldId) {
|
||||||
|
'mcp_server_url' => $this->config->getSystemValue('mcp_server_url', ''),
|
||||||
|
'mcp_server_api_key' => '****', // Never leak the API key on read
|
||||||
|
'astrolabe_client_id' => $this->config->getSystemValue('astrolabe_client_id', ''),
|
||||||
|
'astrolabe_client_secret' => '****', // Never leak the secret on read
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($value !== null) {
|
||||||
|
$event->setValue($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleSetValue(DeclarativeSettingsSetValueEvent $event): void {
|
||||||
|
$fieldId = $event->getFieldId();
|
||||||
|
$value = $event->getValue();
|
||||||
|
|
||||||
|
// Only save if value is not empty (allow clearing by setting to empty string)
|
||||||
|
// For password fields, if the value is '****', don't update (user didn't change it)
|
||||||
|
if ($fieldId === 'mcp_server_api_key' && $value === '****') {
|
||||||
|
$event->stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($fieldId === 'astrolabe_client_secret' && $value === '****') {
|
||||||
|
$event->stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
match($fieldId) {
|
||||||
|
'mcp_server_url' => $this->config->setSystemValue('mcp_server_url', (string)$value),
|
||||||
|
'mcp_server_api_key' => $this->config->setSystemValue('mcp_server_api_key', (string)$value),
|
||||||
|
'astrolabe_client_id' => $this->config->setSystemValue('astrolabe_client_id', (string)$value),
|
||||||
|
'astrolabe_client_secret' => $this->config->setSystemValue('astrolabe_client_secret', (string)$value),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->logger->info('Astrolabe admin setting updated', [
|
||||||
|
'field' => $fieldId,
|
||||||
|
'app' => Application::APP_ID,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Failed to update Astrolabe admin setting', [
|
||||||
|
'field' => $fieldId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'app' => Application::APP_ID,
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,4 +202,176 @@ class McpTokenStorage {
|
|||||||
|
|
||||||
return $token['access_token'];
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-34
@@ -47,11 +47,7 @@ class Admin implements ISettings {
|
|||||||
* @return TemplateResponse
|
* @return TemplateResponse
|
||||||
*/
|
*/
|
||||||
public function getForm(): TemplateResponse {
|
public function getForm(): TemplateResponse {
|
||||||
// Fetch data from MCP server
|
// Get configuration from config.php (local, fast)
|
||||||
$serverStatus = $this->client->getStatus();
|
|
||||||
$vectorSyncStatus = $this->client->getVectorSyncStatus();
|
|
||||||
|
|
||||||
// Get configuration from config.php
|
|
||||||
$serverUrl = $this->config->getSystemValue('mcp_server_url', '');
|
$serverUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||||
$apiKeyConfigured = !empty($this->config->getSystemValue('mcp_server_api_key', ''));
|
$apiKeyConfigured = !empty($this->config->getSystemValue('mcp_server_api_key', ''));
|
||||||
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
|
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
|
||||||
@@ -59,21 +55,6 @@ class Admin implements ISettings {
|
|||||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||||
$clientSecretConfigured = !empty($clientSecret);
|
$clientSecretConfigured = !empty($clientSecret);
|
||||||
|
|
||||||
// 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' => $serverUrl,
|
|
||||||
'help_text' => 'Ensure MCP server is running and accessible. Check config.php for correct mcp_server_url.',
|
|
||||||
],
|
|
||||||
TemplateResponse::RENDER_AS_BLANK
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load search settings from app config
|
// Load search settings from app config
|
||||||
$searchSettings = [
|
$searchSettings = [
|
||||||
'algorithm' => $this->config->getAppValue(
|
'algorithm' => $this->config->getAppValue(
|
||||||
@@ -98,27 +79,19 @@ class Admin implements ISettings {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Provide initial state for Vue.js frontend (if needed)
|
// Provide initial state for Vue.js frontend
|
||||||
$this->initialState->provideInitialState('server-data', [
|
// MCP server data will be fetched asynchronously by Vue component
|
||||||
'serverStatus' => $serverStatus,
|
$this->initialState->provideInitialState('admin-config', [
|
||||||
'vectorSyncStatus' => $vectorSyncStatus,
|
|
||||||
'config' => [
|
'config' => [
|
||||||
'serverUrl' => $serverUrl,
|
'serverUrl' => $serverUrl,
|
||||||
'apiKeyConfigured' => $apiKeyConfigured,
|
'apiKeyConfigured' => $apiKeyConfigured,
|
||||||
|
'clientIdConfigured' => $clientIdConfigured,
|
||||||
|
'clientSecretConfigured' => $clientSecretConfigured,
|
||||||
],
|
],
|
||||||
'searchSettings' => $searchSettings,
|
'searchSettings' => $searchSettings,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$parameters = [
|
$parameters = [];
|
||||||
'serverStatus' => $serverStatus,
|
|
||||||
'vectorSyncStatus' => $vectorSyncStatus,
|
|
||||||
'serverUrl' => $serverUrl,
|
|
||||||
'apiKeyConfigured' => $apiKeyConfigured,
|
|
||||||
'clientIdConfigured' => $clientIdConfigured,
|
|
||||||
'clientSecretConfigured' => $clientSecretConfigured,
|
|
||||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
|
||||||
'searchSettings' => $searchSettings,
|
|
||||||
];
|
|
||||||
|
|
||||||
return new TemplateResponse(
|
return new TemplateResponse(
|
||||||
Application::APP_ID,
|
Application::APP_ID,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\Astrolabe\Settings;
|
||||||
|
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\Settings\DeclarativeSettingsTypes;
|
||||||
|
use OCP\Settings\IDeclarativeSettingsForm;
|
||||||
|
|
||||||
|
class AstrolabeAdminSettings implements IDeclarativeSettingsForm {
|
||||||
|
public function __construct(
|
||||||
|
private IL10N $l,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSchema(): array {
|
||||||
|
return [
|
||||||
|
'id' => 'astrolabe-admin-settings',
|
||||||
|
'priority' => 10,
|
||||||
|
'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN,
|
||||||
|
'section_id' => 'astrolabe',
|
||||||
|
'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL,
|
||||||
|
'title' => $this->l->t('MCP Server Configuration'),
|
||||||
|
'description' => $this->l->t('Configure the connection to your Nextcloud MCP Server'),
|
||||||
|
'doc_url' => 'https://github.com/cbcoutinho/nextcloud-mcp-server',
|
||||||
|
|
||||||
|
'fields' => [
|
||||||
|
[
|
||||||
|
'id' => 'mcp_server_url',
|
||||||
|
'title' => $this->l->t('MCP Server URL'),
|
||||||
|
'description' => $this->l->t('The base URL of your Nextcloud MCP Server instance (e.g., http://localhost:8000)'),
|
||||||
|
'type' => DeclarativeSettingsTypes::URL,
|
||||||
|
'placeholder' => 'http://localhost:8000',
|
||||||
|
'default' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'mcp_server_api_key',
|
||||||
|
'title' => $this->l->t('API Key'),
|
||||||
|
'description' => $this->l->t('Authentication key for the MCP server (leave empty if not required)'),
|
||||||
|
'type' => DeclarativeSettingsTypes::PASSWORD,
|
||||||
|
'placeholder' => $this->l->t('Enter API key'),
|
||||||
|
'default' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'astrolabe_client_id',
|
||||||
|
'title' => $this->l->t('OAuth Client ID'),
|
||||||
|
'description' => $this->l->t('The OAuth client ID for Astrolabe (required for multi-user deployments)'),
|
||||||
|
'type' => DeclarativeSettingsTypes::TEXT,
|
||||||
|
'placeholder' => $this->l->t('Enter OAuth client ID'),
|
||||||
|
'default' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'astrolabe_client_secret',
|
||||||
|
'title' => $this->l->t('OAuth Client Secret'),
|
||||||
|
'description' => $this->l->t('Optional: Client secret for OAuth. If not set, PKCE will be used as fallback.'),
|
||||||
|
'type' => DeclarativeSettingsTypes::PASSWORD,
|
||||||
|
'placeholder' => $this->l->t('Enter client secret (optional)'),
|
||||||
|
'default' => '',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
-2
@@ -55,11 +55,87 @@ class Personal implements ISettings {
|
|||||||
|
|
||||||
$userId = $user->getUID();
|
$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
|
// Check if user has MCP OAuth token
|
||||||
$token = $this->tokenStorage->getUserToken($userId);
|
$token = $this->tokenStorage->getUserToken($userId);
|
||||||
|
|
||||||
// If no token or token is expired, show OAuth authorization UI
|
// For multi_user_basic mode with app password support, check if user has app password
|
||||||
if (!$token || $this->tokenStorage->isExpired($token)) {
|
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');
|
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||||
|
|
||||||
return new TemplateResponse(
|
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)
|
// Provide initial state for Vue.js frontend (if needed)
|
||||||
$this->initialState->provideInitialState('user-data', [
|
$this->initialState->provideInitialState('user-data', [
|
||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
@@ -132,6 +213,9 @@ class Personal implements ISettings {
|
|||||||
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false,
|
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false,
|
||||||
'serverUrl' => $this->client->getPublicServerUrl(),
|
'serverUrl' => $this->client->getPublicServerUrl(),
|
||||||
'hasToken' => true,
|
'hasToken' => true,
|
||||||
|
'hasBackgroundAccess' => $hasBackgroundAccess,
|
||||||
|
'backgroundSyncType' => $backgroundSyncType,
|
||||||
|
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
|
||||||
];
|
];
|
||||||
|
|
||||||
return new TemplateResponse(
|
return new TemplateResponse(
|
||||||
|
|||||||
+1142
-4086
File diff suppressed because it is too large
Load Diff
Vendored
+8
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "0.4.4",
|
"version": "0.7.2",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.0.0",
|
"node": "^22.0.0",
|
||||||
@@ -19,20 +19,23 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nextcloud/axios": "^2.5.1",
|
"@nextcloud/axios": "^2.5.1",
|
||||||
|
"@nextcloud/dialogs": "^7.2.0",
|
||||||
|
"@nextcloud/initial-state": "^3.0.0",
|
||||||
"@nextcloud/l10n": "^3.1.0",
|
"@nextcloud/l10n": "^3.1.0",
|
||||||
"@nextcloud/router": "^3.0.1",
|
"@nextcloud/router": "^3.0.1",
|
||||||
"@nextcloud/vue": "^8.29.2",
|
"@nextcloud/vue": "^9.0.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"plotly.js-dist-min": "^2.35.3",
|
|
||||||
"pdfjs-dist": "^4.0.379",
|
"pdfjs-dist": "^4.0.379",
|
||||||
"vue": "^2.7.16",
|
"plotly.js-dist-min": "^2.35.3",
|
||||||
|
"vue": "^3.0.0",
|
||||||
"vue-material-design-icons": "^5.3.1"
|
"vue-material-design-icons": "^5.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nextcloud/browserslist-config": "3.1.2",
|
"@nextcloud/browserslist-config": "3.1.2",
|
||||||
"@nextcloud/eslint-config": "8.4.2",
|
"@nextcloud/eslint-config": "8.4.2",
|
||||||
"@nextcloud/stylelint-config": "3.1.1",
|
"@nextcloud/stylelint-config": "3.1.1",
|
||||||
"@nextcloud/vite-config": "1.7.2",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
|
"sass-embedded": "^1.97.1",
|
||||||
"terser": "5.44.1",
|
"terser": "5.44.1",
|
||||||
"vite": "7.2.7"
|
"vite": "7.2.7"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+20
-18
@@ -48,10 +48,11 @@
|
|||||||
<div class="mcp-search-card">
|
<div class="mcp-search-card">
|
||||||
<div class="mcp-search-row">
|
<div class="mcp-search-row">
|
||||||
<NcTextField
|
<NcTextField
|
||||||
:value.sync="query"
|
:value="query"
|
||||||
:label="t('astrolabe', 'Search query')"
|
:label="t('astrolabe', 'Search query')"
|
||||||
:placeholder="t('astrolabe', 'Enter your search query...')"
|
:placeholder="t('astrolabe', 'Enter your search query...')"
|
||||||
class="mcp-search-input"
|
class="mcp-search-input"
|
||||||
|
@update:value="query = $event"
|
||||||
@keyup.enter="performSearch" />
|
@keyup.enter="performSearch" />
|
||||||
|
|
||||||
<NcSelect
|
<NcSelect
|
||||||
@@ -104,10 +105,11 @@
|
|||||||
<div class="mcp-option-group">
|
<div class="mcp-option-group">
|
||||||
<label>{{ t('astrolabe', 'Result Limit') }}</label>
|
<label>{{ t('astrolabe', 'Result Limit') }}</label>
|
||||||
<NcTextField
|
<NcTextField
|
||||||
:value.sync="limit"
|
:value="limit"
|
||||||
type="number"
|
type="number"
|
||||||
:min="1"
|
:min="1"
|
||||||
:max="100" />
|
:max="100"
|
||||||
|
@update:value="limit = Number($event)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mcp-option-group">
|
<div class="mcp-option-group">
|
||||||
@@ -152,9 +154,9 @@
|
|||||||
<div class="mcp-viz-header">
|
<div class="mcp-viz-header">
|
||||||
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
|
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
|
||||||
<NcCheckboxRadioSwitch
|
<NcCheckboxRadioSwitch
|
||||||
:checked.sync="showQueryPoint"
|
:checked="showQueryPoint"
|
||||||
type="switch"
|
type="switch"
|
||||||
@update:checked="updatePlot">
|
@update:checked="showQueryPoint = $event; updatePlot()">
|
||||||
{{ t('astrolabe', 'Show query point') }}
|
{{ t('astrolabe', 'Show query point') }}
|
||||||
</NcCheckboxRadioSwitch>
|
</NcCheckboxRadioSwitch>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,17 +366,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
|
import NcContent from '@nextcloud/vue/components/NcContent'
|
||||||
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
|
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
|
||||||
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
|
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||||
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
|
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
|
||||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||||
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
|
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
|
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||||
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
|
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||||
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
|
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||||
|
|
||||||
import Magnify from 'vue-material-design-icons/Magnify.vue'
|
import Magnify from 'vue-material-design-icons/Magnify.vue'
|
||||||
import ChartBox from 'vue-material-design-icons/ChartBox.vue'
|
import ChartBox from 'vue-material-design-icons/ChartBox.vue'
|
||||||
@@ -505,7 +507,7 @@ export default {
|
|||||||
// Check for URL parameters to open chunk viewer
|
// Check for URL parameters to open chunk viewer
|
||||||
this.handleUrlParameters()
|
this.handleUrlParameters()
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeUnmount() {
|
||||||
// Clean up Plotly event handlers to prevent memory leaks
|
// Clean up Plotly event handlers to prevent memory leaks
|
||||||
const plotDiv = document.getElementById('viz-plot')
|
const plotDiv = document.getElementById('viz-plot')
|
||||||
if (plotDiv && plotDiv.on) {
|
if (plotDiv && plotDiv.on) {
|
||||||
@@ -648,7 +650,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggleExcerpt(index) {
|
toggleExcerpt(index) {
|
||||||
this.$set(this.expandedExcerpts, index, !this.expandedExcerpts[index])
|
this.expandedExcerpts[index] = !this.expandedExcerpts[index]
|
||||||
},
|
},
|
||||||
|
|
||||||
truncateExcerpt(text, maxLength = 150) {
|
truncateExcerpt(text, maxLength = 150) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user