Compare commits

...

48 Commits

Author SHA1 Message Date
github-actions[bot] 546f0c0674 bump: version 0.60.2 → 0.60.3 2025-12-31 05:37:15 +00:00
Chris Coutinho e625eab689 Merge pull request #453 from cbcoutinho/fix/452
fix: DeckClient.update_card partial update bugs
2025-12-30 23:36:57 -06:00
Chris Coutinho a26a470af6 fix(deck): Always preserve fields in update_card for partial updates
The Deck PUT API is a full replacement, not a partial update.
Previously, title and description were conditionally sent, causing:
- 400 errors when title not provided (it's required)
- Description being cleared when not explicitly set

Now all required fields (title, type, owner) and description are
always included in the payload using current card values when not
explicitly provided. This matches the existing pattern for type/owner.

Also simplified owner extraction since DeckCard.validate_owner
already ensures it's always a string.

Fixes #452

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 23:30:01 -06:00
Chris Coutinho 71ace47197 test: Define expected partial update behavior for DeckClient.update_card
Refactor tests to assert what SHOULD happen (partial updates preserve
unchanged fields) rather than documenting current buggy behavior.

Tests will fail until fix is implemented in client or upstream.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 22:28:23 -06:00
Chris Coutinho 30d3d9f0cf test: Add integration tests documenting DeckClient.update_card bugs
Tests document current behavior of update_card method:
- Updating without title fails (400) - title required but conditionally sent
- Updating with title clears description - PUT is full replacement

Related: #452

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 11:52:57 -06:00
github-actions[bot] ef9e1b3ff8 bump: version 0.7.1 → 0.7.2 2025-12-30 17:38:00 +00:00
Chris Coutinho dd23191987 fix(astrolabe): Fix CSS loading for Nextcloud apps
Two issues prevented CSS from loading correctly:

1. Entry point naming mismatch: Vite output `main.css` but Nextcloud's
   `Util::addStyle('astrolabe', 'astrolabe-main')` expected `astrolabe-main.css`

2. CSS code splitting: Vite extracted @nextcloud/vue component styles
   into separate chunks (e.g., NcUserBubble-*.css) that Nextcloud doesn't
   load automatically. Without these styles, the UI rendered incorrectly.

Changes:
- Rename entry point from `main` to `astrolabe-main`
- Add `cssCodeSplit: false` to bundle all CSS into the entry point
- Update assetFileNames to output consistent `astrolabe-main.css`

This increases CSS bundle from 11KB to 286KB but ensures all component
styles are available when the page loads.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 11:37:43 -06:00
github-actions[bot] 55312b1032 bump: version 0.7.0 → 0.7.1 2025-12-30 04:50:14 +00:00
Chris Coutinho 48a4182ef9 fix(astrolabe): Fix revoke access button HTTP method mismatch
The "Revoke Access" button in Astrolabe personal settings was failing
with "Unable to connect to server" error in multi-user basic auth mode.

Root cause: The JavaScript sends a POST request but the route was
configured to accept DELETE. Changed the route to:
- Use POST method (matching the JavaScript fetch call)
- Use /api/v1/background-sync/credentials/revoke path (avoiding
  conflict with storeAppPassword which uses POST on the base URL)

Added integration test that verifies the complete revoke flow:
enable background sync → click revoke → verify credentials deleted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 22:49:53 -06:00
Chris Coutinho 13dd709fc2 bump: version 0.56.1 → 0.56.2 2025-12-29 12:18:18 -06:00
github-actions[bot] dd66d4bbbc bump: version 0.60.1 → 0.60.2 2025-12-29 18:15:01 +00:00
Chris Coutinho 663e66af81 fix(oauth): Enable browser OAuth routes for Management API in hybrid mode
The /oauth/login route was returning 404 in multi-user BasicAuth mode with
offline access enabled. This was because browser OAuth routes were gated
by `oauth_enabled` (only True for MCP OAuth modes), not by
`oauth_provisioning_available` which correctly includes hybrid mode.

The Management API (admin UI, webhook management) requires OAuth
authentication regardless of how MCP tools authenticate. These are
independent security concerns:
- MCP Tools: BasicAuth (waiting for upstream Nextcloud OAuth patches)
- Management API: OAuth (for admin UI, webhook management, vector sync)

Changes:
- Gate browser OAuth routes by oauth_provisioning_available instead of
  oauth_enabled
- Add follow_redirects=True to OIDC discovery HTTP clients

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:14:26 -06:00
Chris Coutinho 9c17bbfe9c bump: version 0.56.0 → 0.56.1 2025-12-26 10:33:20 -06:00
github-actions[bot] 052db2cf56 bump: version 0.60.0 → 0.60.1 2025-12-26 16:05:51 +00:00
Chris Coutinho 056414752e fix(mcp): Move all imports to the top of modules 2025-12-26 10:05:27 -06:00
github-actions[bot] b841407f07 bump: version 0.6.0 → 0.7.0 2025-12-26 15:17:32 +00:00
github-actions[bot] 555c26526e bump: version 0.55.2 → 0.56.0 2025-12-26 15:17:31 +00:00
github-actions[bot] 5b9e91bdee bump: version 0.59.1 → 0.60.0 2025-12-26 15:17:31 +00:00
Chris Coutinho 5d49b5903a Merge pull request #448 from cbcoutinho/feat/improve-admin-ux-vue3
feat/improve admin ux vue3
2025-12-26 09:17:11 -06:00
Chris Coutinho 9a6a253858 fix(tests): Add singleton reset fixture to prevent anyio.WouldBlock errors
Add module-scoped autouse fixture `reset_all_singletons` in
tests/integration/conftest.py that resets all global singletons
between test modules:

- _qdrant_client (vector/qdrant_client.py)
- _embedding_service, _bm25_service (embedding/service.py)
- _provider (providers/registry.py)
- _vector_sync_state with memory streams (app.py)
- _tracer (observability/tracing.py)
- _registry (auth/client_registry.py)
- _token_exchange_service (auth/token_exchange.py)

This fixes anyio.WouldBlock errors that occurred when running the
full integration test suite together. The errors were caused by
stale singleton state holding references to dead event loops or
closed memory streams from previous test modules.

Results:
- Before: 22 passed, 26 errors (WouldBlock), 12 failed
- After: 48 passed, 25 skipped, 1 failed (unrelated timeout)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 09:12:21 -06:00
Chris Coutinho 0a23e484e9 docs(auth): Update docstrings of management api auth handling 2025-12-26 09:05:04 -06:00
Chris Coutinho 779d474aaa fix(tests): Fix integration test failures in qdrant, sampling, and rag tests
- test_qdrant_collection_creation.py:
  - Add get_vector_params() helper to handle named vectors format
  - Collections use {"dense": VectorParams(...)} instead of direct VectorParams
  - Fix otel_service_name setting in test_collection_name_generation

- test_sampling.py:
  - Fix MCP response parsing: use json.loads(result.content[0].text)
    instead of result.structuredContent (which is None)
  - Add require_vector_sync_tools() helper for graceful skipping
  - Add helper call to all 5 test functions

- test_rag.py:
  - Add require_vector_sync_tools() helper for graceful skipping
  - Fix MCP response parsing (same as sampling tests)
  - Prevents 600s timeout when VECTOR_SYNC_ENABLED is not set

Tests now pass/skip cleanly when run independently. The anyio.WouldBlock
errors in full test suite runs are fixture isolation issues, not code bugs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:59:44 -06:00
Chris Coutinho 894bf5f916 refactor(auth): Decouple BasicAuth and OAuth authentication strategies
Completely separates multi-user BasicAuth mode from OAuth mode with no
fallback between them. These are now mutually exclusive authentication
strategies based on deployment configuration.

Changes:
- Create separate functions: get_user_client_basic_auth() and
  get_user_client_oauth() with clear separation of concerns
- Update get_user_client() to dispatch based on use_basic_auth parameter
- Pass use_basic_auth through all background sync tasks
- Update app.py to determine auth mode at startup
- Rewrite integration tests to verify no OAuth fallback in BasicAuth mode
- Fix test assertions for response field names and duplicate title handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:27:15 -06:00
Chris Coutinho 804480836e fix(auth): Skip issuer validation for management API tokens
Fixes NC PHP app (Astrolabe) OAuth integration by making token validation
more lenient for management API access.

Problem:
- Astrolabe calls Nextcloud OIDC token endpoint via internal URL (http://localhost)
- Tokens are issued with iss: http://localhost (internal)
- MCP server expects iss: http://localhost:8080 (external)
- Token validation failed with "Invalid issuer"

Solution:
- Add skip_issuer_check parameter to _verify_jwt_signature()
- verify_token_for_management_api() now skips both audience and issuer checks
- Security maintained: signature still verified, authorization checked by API

Also includes related fixes from previous session:
- Update test selectors for Vue 3 UI ("Enable Semantic Search")
- Fix OIDC discovery URL transformation in OAuthController.php
- Add overwrite.cli.url to setup hook for proper external URLs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 17:25:48 -06:00
Chris Coutinho 5e2ef5f35b chore: lint 2025-12-24 09:52:45 -06:00
Chris Coutinho a51376fd5a fix: Use settings.enable_offline_access for env var consolidation
Migrate all direct ENABLE_OFFLINE_ACCESS environment variable checks to
use settings.enable_offline_access, which handles both the new
ENABLE_BACKGROUND_OPERATIONS and deprecated ENABLE_OFFLINE_ACCESS vars.

Also fixes JWT issuer validation in Docker by using NEXTCLOUD_PUBLIC_ISSUER_URL
when set, resolving 401 errors caused by internal/external URL mismatch.

Changes:
- app.py: Use settings for offline access checks in setup_oauth_config,
  register_oauth_client, and tool registration
- oauth_tools.py: Use settings in provision_nextcloud_access and check_logged_in
- management.py: Use settings in get_user_session
- scope_authorization.py: Use settings in require_scopes decorator
- Remove unused os imports after migration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:10:01 -06:00
Chris Coutinho 10a0969138 fix: Add required config.py attributes 2025-12-23 11:57:30 -07:00
Chris Coutinho 5e76ddc60d feat: Remove URL rewriting in favor of proper nextcloud config
Remove URL rewriting logic from MCP server that was converting
      public URLs to internal Docker URLs. This was a workaround for
      Nextcloud's overwritehost setting forcing URLs to localhost:8080.

      Changes:
      - Remove OIDC endpoint rewriting in app.py (setup_oauth_config)
      - Remove OIDC_JWKS_URI override support (no longer needed)
      - Remove URL rewriting in browser_oauth_routes.py
      - Remove URL rewriting in token_broker.py
      - Update Helm chart values and README
      - Add hybrid auth setup unit tests
      - Update Astrolabe admin UI for Vue 3

      The proper fix is in the previous commit which removes the
      overwritehost setting from Nextcloud, allowing it to respect
      the Host header from incoming requests.
2025-12-23 11:34:57 -07:00
Chris Coutinho 9ea1902e2b fix(docker): remove overwritehost to fix container-to-container DCR
Remove the overwritehost and overwrite.cli.url settings that were forcing
Nextcloud to generate URLs with localhost:8080 regardless of the incoming
request's Host header.

This was breaking Dynamic Client Registration (DCR) from the mcp-oauth
container, which needs to reach Nextcloud at http://app:80 but was getting
discovery documents with http://localhost:8080 URLs that are unreachable
from inside the Docker network.

Now Nextcloud respects the Host header:
- Browser requests to localhost:8080 → returns localhost:8080 URLs
- Container requests to app:80 → returns app:80 URLs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:28:47 -07:00
Chris Coutinho dd42849d70 feat(helm): migrate to new environment variable naming convention
Replace deprecated environment variables with new consolidated names:
- VECTOR_SYNC_ENABLED → ENABLE_SEMANTIC_SEARCH
- ENABLE_OFFLINE_ACCESS → ENABLE_BACKGROUND_OPERATIONS

Update values.yaml structure:
- Rename 'vectorSync' section to 'semanticSearch'
- Update descriptions to emphasize BM25 hybrid search

Benefits:
- Aligns with application-level config consolidation
- Clearer naming: "semantic search" vs "vector sync"
- Maintains backward compatibility via application deprecation handling
- Automatic enablement of background ops when semantic search enabled in multi-user modes

Updated files:
- values.yaml: Renamed vectorSync → semanticSearch
- deployment.yaml: New env var names with deprecation comments
- NOTES.txt: Updated deployment notes
- README.md: Updated documentation and examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 09:12:07 -07:00
Chris Coutinho 4248b67b2e feat: Migrate to vue 3 2025-12-23 05:46:49 +01:00
github-actions[bot] 755e398a1f bump: version 0.59.0 → 0.59.1 2025-12-22 23:49:27 +00:00
Chris Coutinho 036c6352fb Merge pull request #404 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to 7145c3e
2025-12-23 00:49:07 +01:00
Chris Coutinho d7c99fcc69 feat(astrolabe): upgrade to Vue 3 and @nextcloud/vue 9
- Merge renovate/major-vue-monorepo: Vue 2.7.16 → 3.5.26
- Merge renovate/nextcloud-vue-9.x: @nextcloud/vue 8.29.2 → 9.3.1
- Update component imports to new @nextcloud/vue v9 paths
- Replace .sync modifiers with v-model:prop (Vue 3 syntax)
- Replace beforeDestroy with beforeUnmount lifecycle hook
- Remove Vue.() usage (automatic reactivity in Vue 3)
- Update main.js to use createApp() instead of Vue.extend()
- Add @vitejs/plugin-vue and configure Vite for Vue 3
- All builds passing, ready for admin UX improvements
2025-12-23 00:47:21 +01:00
Chris Coutinho 47095fabcd Merge remote-tracking branch 'origin/renovate/nextcloud-vue-9.x' into feat/improve-admin-ux-vue3
# Conflicts:
#	third_party/astrolabe/package-lock.json
2025-12-23 00:38:50 +01:00
Chris Coutinho 85b7b935b3 Merge remote-tracking branch 'origin/renovate/major-vue-monorepo' into feat/improve-admin-ux-vue3 2025-12-23 00:36:51 +01:00
github-actions[bot] 6e2be579e0 bump: version 0.55.1 → 0.55.2 2025-12-22 21:21:45 +00:00
Chris Coutinho 8ba3ae73ab fix(helm): set OIDC client env vars when using existingSecret
The deployment template only checked for clientId being set in
values.yaml, so when using existingSecret without setting clientId,
the NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET env
vars were never created.

This broke existingSecret for OIDC-based auth - the server would
always fall back to DCR even when pre-registered credentials were
provided via secret.

Fix: Check for EITHER clientId OR existingSecret being set before
creating the OIDC client credential env vars.

Affects both OIDC-based auth modes:
- auth.oauth.existingSecret (OAuth mode)
- auth.multiUserBasic.existingSecret (multi-user BasicAuth with offline access)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-22 22:21:23 +01:00
github-actions[bot] dbf3d5ec10 bump: version 0.55.0 → 0.55.1 2025-12-22 20:53:07 +00:00
Chris Coutinho 5b9e76ddb4 fix(helm): trigger chart release workflow on helm chart tags
The helm-release workflow was only triggering on v* tags (MCP server
releases), not on nextcloud-mcp-server-* tags (helm chart releases).

This caused chart releases to be skipped because:
1. Helm chart version bump creates tag nextcloud-mcp-server-X.Y.Z
2. Workflow never runs for this tag (pattern didn't match)
3. Next v* tag triggers workflow at wrong commit (Chart.yaml not updated)
4. chart-releaser skips because version already exists

Fix: Add nextcloud-mcp-server-* to workflow trigger pattern so chart
releases execute at the correct commit where Chart.yaml has the new version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-22 21:48:26 +01:00
github-actions[bot] 541f7a6abd bump: version 0.54.0 → 0.55.0 2025-12-22 20:35:14 +00:00
github-actions[bot] 28cfee4bab bump: version 0.58.0 → 0.59.0 2025-12-22 20:35:13 +00:00
Chris Coutinho 358d962822 Merge pull request #447 from cbcoutinho/feature/helm-chart-multi-user-basic-support
feat(helm): add multi-user BasicAuth mode support
2025-12-22 21:34:53 +01:00
github-actions[bot] ff8828e972 bump: version 0.5.0 → 0.6.0 2025-12-22 18:49:32 +00:00
github-actions[bot] 43c7421d28 bump: version 0.57.0 → 0.58.0 2025-12-22 18:49:31 +00:00
renovate-bot-cbcoutinho[bot] d29922039b fix(deps): update dependency vue to v3 2025-12-20 11:18:53 +00:00
renovate-bot-cbcoutinho[bot] 12541e57a6 fix(deps): update dependency @nextcloud/vue to v9 2025-12-20 11:18:10 +00:00
renovate-bot-cbcoutinho[bot] b99418451c chore(deps): update anthropics/claude-code-action digest to 7145c3e 2025-12-20 11:12:24 +00:00
88 changed files with 4569 additions and 5533 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+1
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- v*
- nextcloud-mcp-server-*
jobs:
release:
+70
View File
@@ -5,6 +5,76 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
## v0.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
+7 -1
View File
@@ -99,7 +99,7 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
### Authentication Modes
The server supports two authentication modes:
The server supports three authentication modes:
**Single-User Mode (BasicAuth):**
- 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
- 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.
## Semantic Search
@@ -4,8 +4,8 @@ set -euox pipefail
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)
# These ensure that URLs generated by Nextcloud include the correct host:port
php /var/www/html/occ config:system:set overwritehost --value="localhost:8080"
php /var/www/html/occ config:system:set overwriteprotocol --value="http"
# Set overwrite.cli.url to the external URL for OIDC discovery
# This ensures OAuth flows redirect to the correct external URL
# Important: The Astrolabe OAuth controller makes internal HTTP requests to /.well-known/openid-configuration
# 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"
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.54.0"
version = "0.56.2"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
+87
View File
@@ -14,6 +14,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable resource limits
- 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)
### Feat
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application
version: 0.54.0
appVersion: "0.57.0"
version: 0.56.2
appVersion: "0.60.3"
keywords:
- nextcloud
- mcp
+16 -14
View File
@@ -99,11 +99,11 @@ ingress:
|-----------|-------------|---------|
| `nextcloud.host` | URL of your Nextcloud instance (required) | `""` |
| `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:**
- `*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
@@ -208,16 +208,16 @@ The application exposes HTTP health check endpoints:
#### 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 |
|-----------|-------------|---------|
| `vectorSync.enabled` | Enable background vector synchronization | `false` |
| `vectorSync.scanInterval` | Scan interval in seconds | `3600` |
| `vectorSync.processorWorkers` | Number of concurrent processor workers | `3` |
| `vectorSync.queueMaxSize` | Maximum queue size for pending documents | `10000` |
| `semanticSearch.enabled` | Enable semantic search and background vector synchronization | `false` |
| `semanticSearch.scanInterval` | Scan interval in seconds | `3600` |
| `semanticSearch.processorWorkers` | Number of concurrent processor workers | `3` |
| `semanticSearch.queueMaxSize` | Maximum queue size for pending documents | `10000` |
**Document Chunking Configuration:**
@@ -427,7 +427,7 @@ nextcloud:
host: https://cloud.example.com
# mcpServerUrl and publicIssuerUrl are optional!
# 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:
mode: oauth
@@ -459,7 +459,7 @@ This example shows OAuth without pre-registered credentials (using DCR) and opti
nextcloud:
host: https://cloud.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:
mode: oauth
@@ -537,8 +537,8 @@ auth:
username: admin
password: secure-password
# Enable vector sync
vectorSync:
# Enable semantic search
semanticSearch:
enabled: true
scanInterval: 1800 # Scan every 30 minutes
processorWorkers: 5
@@ -576,7 +576,7 @@ ollama:
Or use an external Ollama instance:
```yaml
vectorSync:
semanticSearch:
enabled: true
qdrant:
@@ -592,7 +592,7 @@ ollama:
Or use OpenAI for embeddings:
```yaml
vectorSync:
semanticSearch:
enabled: true
qdrant:
@@ -689,7 +689,9 @@ Readiness (returns 200 if ready, 503 if not ready):
1. **Connection refused to Nextcloud**
- 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
- Note: Do not use internal Docker hostnames (like `http://app:80`) for `nextcloud.host` - use externally resolvable URLs
2. **Authentication failures**
- 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 }}
{{- if .Values.vectorSync.enabled }}
{{- if .Values.semanticSearch.enabled }}
5. Vector Search & Semantic Capabilities:
- Vector Sync: Enabled
- Scan Interval: {{ .Values.vectorSync.scanInterval }}s
- Processor Workers: {{ .Values.vectorSync.processorWorkers }}
5. Semantic Search & Vector Capabilities:
- Semantic Search: Enabled
- Scan Interval: {{ .Values.semanticSearch.scanInterval }}s
- Processor Workers: {{ .Values.semanticSearch.processorWorkers }}
{{- if .Values.qdrant.enabled }}
- Qdrant: Deployed as subchart ({{ .Release.Name }}-qdrant:6333)
{{- else }}
@@ -88,8 +88,8 @@ spec:
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
# Background operations with app passwords
- name: ENABLE_OFFLINE_ACCESS
# 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 }}
@@ -100,7 +100,7 @@ spec:
key: {{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}
- name: NEXTCLOUD_OIDC_SCOPES
value: {{ .Values.auth.multiUserBasic.scopes | quote }}
{{- if .Values.auth.multiUserBasic.clientId }}
{{- 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:
@@ -122,7 +122,7 @@ spec:
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
- name: NEXTCLOUD_OIDC_SCOPES
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
valueFrom:
secretKeyRef:
@@ -182,16 +182,16 @@ spec:
value: {{ .Values.documentProcessing.custom.types | quote }}
{{- end }}
{{- end }}
# Vector Sync
- name: VECTOR_SYNC_ENABLED
value: {{ .Values.vectorSync.enabled | quote }}
{{- if .Values.vectorSync.enabled }}
# Semantic Search (replaces deprecated VECTOR_SYNC_ENABLED)
- name: ENABLE_SEMANTIC_SEARCH
value: {{ .Values.semanticSearch.enabled | quote }}
{{- if .Values.semanticSearch.enabled }}
- name: VECTOR_SYNC_SCAN_INTERVAL
value: {{ .Values.vectorSync.scanInterval | quote }}
value: {{ .Values.semanticSearch.scanInterval | quote }}
- name: VECTOR_SYNC_PROCESSOR_WORKERS
value: {{ .Values.vectorSync.processorWorkers | quote }}
value: {{ .Values.semanticSearch.processorWorkers | quote }}
- name: VECTOR_SYNC_QUEUE_MAX_SIZE
value: {{ .Values.vectorSync.queueMaxSize | quote }}
value: {{ .Values.semanticSearch.queueMaxSize | quote }}
{{- end }}
# Document Chunking (always set, used by vector sync processor)
- name: DOCUMENT_CHUNK_SIZE
+16 -8
View File
@@ -26,9 +26,16 @@ nextcloud:
# Example: https://mcp.example.com
mcpServerUrl: ""
# Public issuer URL for OAuth (OAuth mode only)
# If not specified, defaults to nextcloud.host
# Only set this if your Nextcloud is accessible at a different URL for OAuth
# Public issuer URL for browser-accessible OAuth authorization endpoints (OAuth mode only)
# ONLY used to make authorization endpoints accessible to users' browsers
# 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
publicIssuerUrl: ""
@@ -358,10 +365,11 @@ extraEnvFrom: []
# - secretRef:
# name: my-secret
# Vector Sync Configuration
# Background synchronization of Nextcloud content into vector database for semantic search
vectorSync:
# Enable background vector synchronization
# Semantic Search Configuration
# Enable semantic search with BM25 hybrid search and background synchronization
# of Nextcloud content into vector database
semanticSearch:
# Enable semantic search and background vector synchronization
enabled: false
# Scan interval in seconds (how often to check for changes)
scanInterval: 3600
@@ -372,7 +380,7 @@ vectorSync:
# Document Chunking Configuration
# Controls how documents are split into chunks before embedding
# Only relevant when vectorSync.enabled is true
# Only relevant when semanticSearch.enabled is true
documentChunking:
# Number of words per chunk (default: 512)
# Smaller chunks (256-384): Better for precise searches, more chunks to store
+10 -5
View File
@@ -8,6 +8,8 @@ services:
command: --transaction-isolation=READ-COMMITTED
volumes:
- db:/var/lib/mysql
ports:
- 127.0.0.1:3306:3306
environment:
- MYSQL_ROOT_PASSWORD=password
- MYSQL_PASSWORD=password
@@ -24,7 +26,7 @@ services:
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
restart: always
ports:
- 0.0.0.0:8080:80
- 127.0.0.1:8080:80
depends_on:
- redis
- db
@@ -138,7 +140,7 @@ services:
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- ENABLE_MULTI_USER_BASIC_AUTH=true
- ENABLE_OFFLINE_ACCESS=true
#- ENABLE_OFFLINE_ACCESS=true
- ENABLE_BACKGROUND_OPERATIONS=true
# Token storage (required for middleware initialization)
@@ -178,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
# 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_STORAGE_DB=/app/data/tokens.db
@@ -187,7 +190,8 @@ services:
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
# Vector sync configuration (ADR-007)
- VECTOR_SYNC_ENABLED=true
- ENABLE_SEMANTIC_SEARCH=true
#- VECTOR_SYNC_ENABLED=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -255,7 +259,8 @@ services:
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
# 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_STORAGE_DB=/app/data/tokens.db
+87
View File
@@ -140,6 +140,93 @@ Basic Authentication uses username and password credentials directly.
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
- [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
The server automatically detects the authentication mode:
+27 -17
View File
@@ -11,11 +11,11 @@ The PHP app obtains tokens through PKCE flow and uses them to access these endpo
"""
import logging
import os
import time
from importlib.metadata import version
from typing import Any
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse
@@ -55,6 +55,22 @@ async def validate_token_and_get_user(
) -> tuple[str, dict[str, Any]]:
"""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:
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
token_verifier = request.app.state.oauth_context["token_verifier"]
# Validate token (handles both JWT and opaque tokens)
# verify_token returns AccessToken object or None
access_token = await token_verifier.verify_token(token)
# Validate token for management API (handles both JWT and opaque tokens)
# Uses verify_token_for_management_api which accepts any valid Nextcloud 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:
raise ValueError("Token validation failed")
@@ -347,11 +364,12 @@ async def get_user_session(request: Request) -> JSONResponse:
)
# Check if offline access is enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
"true",
"1",
"yes",
)
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
# 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
if not enable_offline_access:
# Offline access disabled - return minimal session info
@@ -513,8 +531,6 @@ async def get_installed_apps(request: Request) -> JSONResponse:
)
try:
import httpx
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
@@ -585,8 +601,6 @@ async def list_webhooks(request: Request) -> JSONResponse:
)
try:
import httpx
from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Get Bearer token from request
@@ -652,8 +666,6 @@ async def create_webhook(request: Request) -> JSONResponse:
)
try:
import httpx
from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Parse request body
@@ -730,8 +742,6 @@ async def delete_webhook(request: Request) -> JSONResponse:
)
try:
import httpx
from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Get webhook_id from path parameter
+320 -137
View File
@@ -1,3 +1,7 @@
from __future__ import annotations
import base64
import json
import logging
import os
import time
@@ -7,14 +11,15 @@ from contextlib import AsyncExitStack, asynccontextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, cast
from urllib.parse import urlparse
import anyio
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
if TYPE_CHECKING:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
import anyio
import click
import httpx
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
@@ -42,6 +47,7 @@ from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import (
DeploymentMode,
Settings,
get_document_processor_config,
get_settings,
)
@@ -380,8 +386,6 @@ class BasicAuthMiddleware:
if auth_header.startswith(b"Basic "):
try:
import base64
# Decode base64(username:password)
encoded = auth_header[6:] # Skip "Basic "
decoded = base64.b64decode(encoded).decode("utf-8")
@@ -535,11 +539,10 @@ async def load_oauth_client_credentials(
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write news:read news:write"
# Add offline_access scope if refresh tokens are enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
"true",
"1",
"yes",
)
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
dcr_settings = get_settings()
enable_offline_access = dcr_settings.enable_offline_access
if enable_offline_access:
dcr_scopes = f"{dcr_scopes} offline_access"
logger.info("✓ offline_access scope enabled for refresh tokens")
@@ -668,6 +671,10 @@ async def setup_oauth_config():
Returns:
Tuple of (nextcloud_host, token_verifier, auth_settings, refresh_token_storage, oauth_client, oauth_provider, client_id, client_secret)
"""
# Get settings for enable_offline_access check (handles both ENABLE_BACKGROUND_OPERATIONS
# and ENABLE_OFFLINE_ACCESS environment variables)
settings = get_settings()
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
raise ValueError(
@@ -683,7 +690,7 @@ async def setup_oauth_config():
logger.info(f"Performing OIDC discovery: {discovery_url}")
# Perform OIDC discovery
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -700,36 +707,6 @@ async def setup_oauth_config():
introspection_uri = discovery.get("introspection_endpoint")
registration_endpoint = discovery.get("registration_endpoint")
# Allow overriding JWKS URI (useful when running in Docker with frontendUrl)
# Example: frontendUrl=http://localhost:8888 but MCP server needs http://keycloak:8080
jwks_uri_override = os.getenv("OIDC_JWKS_URI")
if jwks_uri_override:
logger.info(f"OIDC_JWKS_URI override: {jwks_uri}{jwks_uri_override}")
jwks_uri = jwks_uri_override
# Rewrite discovered endpoint URLs from public issuer to internal host
# This is needed when OIDC discovery returns public URLs (e.g., http://localhost:8080)
# but the server needs to access them via internal docker network (e.g., http://app:80)
from urllib.parse import urlparse
issuer_parsed = urlparse(issuer)
nextcloud_parsed = urlparse(nextcloud_host)
issuer_base = f"{issuer_parsed.scheme}://{issuer_parsed.netloc}"
nextcloud_base = f"{nextcloud_parsed.scheme}://{nextcloud_parsed.netloc}"
if issuer_base != nextcloud_base:
logger.info(f"Rewriting OIDC endpoints: {issuer_base}{nextcloud_base}")
def rewrite_url(url: str | None) -> str | None:
if url and url.startswith(issuer_base):
return url.replace(issuer_base, nextcloud_base, 1)
return url
userinfo_uri = rewrite_url(userinfo_uri) or userinfo_uri
jwks_uri = rewrite_url(jwks_uri)
introspection_uri = rewrite_url(introspection_uri)
registration_endpoint = rewrite_url(registration_endpoint)
logger.info("OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
@@ -756,16 +733,8 @@ async def setup_oauth_config():
issuer_normalized = normalize_url(issuer)
nextcloud_normalized = normalize_url(nextcloud_host)
# Use NEXTCLOUD_PUBLIC_ISSUER_URL for IdP detection when set
# This handles the case where MCP server accesses Nextcloud via internal URL (http://app:80)
# but the issuer in OIDC discovery is the public URL (http://localhost:8080)
public_issuer_for_detection = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer_for_detection:
comparison_issuer = normalize_url(public_issuer_for_detection)
else:
comparison_issuer = nextcloud_normalized
is_external_idp = not issuer_normalized.startswith(comparison_issuer)
# Determine if this is an external IdP by comparing discovered issuer with Nextcloud host
is_external_idp = not issuer_normalized.startswith(nextcloud_normalized)
if is_external_idp:
oauth_provider = "external" # Could be Keycloak, Auth0, Okta, etc.
@@ -777,34 +746,10 @@ async def setup_oauth_config():
oauth_provider = "nextcloud"
logger.info("✓ Detected integrated mode (Nextcloud OIDC app)")
# For integrated mode, rewrite OIDC endpoints to use internal URL
# The discovery document returns external URLs (http://localhost:8080)
# but the MCP server needs internal URLs (http://app:80) for backend requests
if jwks_uri and not os.getenv("OIDC_JWKS_URI"):
internal_jwks_uri = f"{nextcloud_host}/apps/oidc/jwks"
logger.info(
f" Auto-rewriting JWKS URI for internal access: {jwks_uri}{internal_jwks_uri}"
)
jwks_uri = internal_jwks_uri
if introspection_uri and not os.getenv("OIDC_INTROSPECTION_URI"):
internal_introspection_uri = f"{nextcloud_host}/apps/oidc/introspect"
logger.info(
f" Auto-rewriting introspection URI for internal access: {introspection_uri}{internal_introspection_uri}"
)
introspection_uri = internal_introspection_uri
if userinfo_uri:
internal_userinfo_uri = f"{nextcloud_host}/apps/oidc/userinfo"
logger.info(
f" Auto-rewriting userinfo URI for internal access: {userinfo_uri}{internal_userinfo_uri}"
)
userinfo_uri = internal_userinfo_uri
# Check if offline access (refresh tokens) is enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
"true",
"1",
"yes",
)
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
enable_offline_access = settings.enable_offline_access
# Initialize refresh token storage if enabled
refresh_token_storage = None
@@ -854,21 +799,11 @@ async def setup_oauth_config():
f"Discovery URL: {discovery_url}"
)
# Handle public issuer override (for clients accessing via different URL)
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
# but the MCP server accesses via internal URL (e.g., http://app:80),
# we need to use the public URL for JWT validation and client configuration
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(
f"Using public issuer URL override for JWT validation: {public_issuer}"
)
client_issuer = public_issuer
else:
client_issuer = issuer
# ADR-005: Unified Token Verifier with proper audience validation
# Use public issuer URL for JWT validation if set (handles Docker internal/external URL mismatch)
# Tokens are issued with the public URL, but OIDC discovery returns internal URL
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
client_issuer = public_issuer_url if public_issuer_url else issuer
# Get MCP server URL for audience validation
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
@@ -885,10 +820,8 @@ async def setup_oauth_config():
"This should be set explicitly for proper audience validation."
)
# Create settings for UnifiedTokenVerifier
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
# Create settings for UnifiedTokenVerifier (use same settings instance from start of function)
# settings is already set at the start of setup_oauth_config()
# Override with discovered values if not set in environment
if not settings.oidc_client_id:
settings.oidc_client_id = client_id
@@ -1012,6 +945,166 @@ async def setup_oauth_config():
)
async def setup_oauth_config_for_multi_user_basic(
settings: Settings,
client_id: str,
client_secret: str,
) -> tuple[UnifiedTokenVerifier, RefreshTokenStorage | None, str, str]:
"""
Setup minimal OAuth configuration for multi-user BasicAuth mode.
This is a lightweight version of setup_oauth_config() that:
- Performs OIDC discovery to get endpoints
- Creates UnifiedTokenVerifier for management API token validation
- Creates RefreshTokenStorage for webhook token storage
- Skips OAuth client creation (not needed for BasicAuth background sync)
- Skips AuthSettings creation (not needed for BasicAuth MCP operations)
This enables hybrid authentication mode where:
- MCP operations use BasicAuth (stateless, simple)
- Management APIs use OAuth bearer tokens (secure, per-user)
- Background operations use OAuth refresh tokens (webhook sync)
Args:
settings: Application settings
client_id: OAuth client ID (from DCR or static config)
client_secret: OAuth client secret
Returns:
Tuple of (token_verifier, refresh_token_storage, client_id, client_secret)
Raises:
ValueError: If NEXTCLOUD_HOST is not set
httpx.HTTPError: If OIDC discovery fails
"""
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
raise ValueError("NEXTCLOUD_HOST is required for OAuth infrastructure setup")
nextcloud_host = nextcloud_host.rstrip("/")
# Get OIDC discovery URL (always Nextcloud integrated mode for multi-user BasicAuth)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host}/.well-known/openid-configuration",
)
logger.info(
f"Performing OIDC discovery for multi-user BasicAuth hybrid mode: {discovery_url}"
)
# Perform OIDC discovery
try:
async with httpx.AsyncClient(
timeout=30.0, follow_redirects=True
) as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
except httpx.HTTPStatusError as e:
logger.error(
f"OIDC discovery failed: HTTP {e.response.status_code} from {discovery_url}"
)
raise ValueError(
f"OIDC discovery failed: HTTP {e.response.status_code} from {discovery_url}. "
"Ensure Nextcloud OIDC (user_oidc app) is installed and configured."
) from e
except httpx.RequestError as e:
logger.error(f"OIDC discovery failed: {e}")
raise ValueError(
f"OIDC discovery failed: Cannot connect to {discovery_url}. Error: {e}"
) from e
except (KeyError, ValueError) as e:
logger.error(
f"OIDC discovery failed: Invalid response from {discovery_url}: {e}"
)
raise ValueError(
f"OIDC discovery failed: Invalid response from {discovery_url}. "
"The endpoint did not return valid OIDC configuration."
) from e
logger.info("✓ OIDC discovery successful (multi-user BasicAuth)")
# Extract OIDC endpoints from discovery
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
jwks_uri = discovery.get("jwks_uri")
introspection_uri = discovery.get("introspection_endpoint")
logger.info("OIDC endpoints configured for management API:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
logger.info(f" JWKS: {jwks_uri}")
logger.info(f" Introspection: {introspection_uri}")
# Get MCP server URL for audience validation
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
# Use public issuer URL for JWT validation if set (handles Docker internal/external URL mismatch)
# Tokens are issued with the public URL, but OIDC discovery returns internal URL
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
client_issuer = public_issuer_url if public_issuer_url else issuer
# Update settings with discovered values for UnifiedTokenVerifier
if not settings.oidc_client_id:
settings.oidc_client_id = client_id
if not settings.oidc_client_secret:
settings.oidc_client_secret = client_secret
if not settings.jwks_uri:
settings.jwks_uri = jwks_uri
if not settings.introspection_uri:
settings.introspection_uri = introspection_uri
if not settings.userinfo_uri:
settings.userinfo_uri = userinfo_uri
if not settings.oidc_issuer:
settings.oidc_issuer = client_issuer
if not settings.nextcloud_mcp_server_url:
settings.nextcloud_mcp_server_url = mcp_server_url
if not settings.nextcloud_resource_uri:
settings.nextcloud_resource_uri = nextcloud_resource_uri
# Create Unified Token Verifier for management API authentication
token_verifier = UnifiedTokenVerifier(settings)
logger.info("✓ Token verifier created for management API (hybrid mode)")
if introspection_uri:
logger.info(" Opaque token introspection enabled (RFC 7662)")
if jwks_uri:
logger.info(" JWT signature verification enabled (JWKS)")
# Initialize refresh token storage for background operations
refresh_token_storage = None
if settings.enable_offline_access:
try:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
if not encryption_key:
logger.warning(
"ENABLE_OFFLINE_ACCESS=true but TOKEN_ENCRYPTION_KEY not set. "
"Refresh tokens will NOT be stored. Generate a key with:\n"
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
else:
refresh_token_storage = RefreshTokenStorage.from_env()
await refresh_token_storage.initialize()
logger.info(
"✓ Refresh token storage initialized for background operations (hybrid mode)"
)
except Exception as e:
logger.error(f"Failed to initialize refresh token storage: {e}")
logger.debug(f"Full traceback:\n{traceback.format_exc()}")
logger.warning(
"Continuing without refresh token storage - webhook management may be limited"
)
logger.info(
"OAuth infrastructure setup complete for multi-user BasicAuth hybrid mode"
)
return (token_verifier, refresh_token_storage, client_id, client_secret)
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
# Initialize observability (logging will be configured by uvicorn)
settings = get_settings()
@@ -1043,6 +1136,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
else DeploymentMode.SELF_HOSTED
)
# Log hybrid authentication status for multi-user BasicAuth with offline access
if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access:
logger.info(
"🔄 Hybrid authentication mode will be enabled:\n"
" - MCP operations: BasicAuth (stateless, credentials per-request)\n"
" - Management APIs: OAuth bearer tokens (secure, per-user)\n"
" - Background operations: OAuth refresh tokens (webhook sync)"
)
# Setup Prometheus metrics (always enabled by default)
if settings.metrics_enabled:
setup_metrics(port=settings.metrics_port)
@@ -1070,17 +1172,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# This must happen BEFORE uvicorn starts (same lifecycle point as OAuth modes)
# to avoid async context issues
multi_user_basic_oauth_creds: tuple[str, str] | None = None
multi_user_token_verifier: UnifiedTokenVerifier | None = None
multi_user_refresh_storage: RefreshTokenStorage | None = None
if (
mode == AuthMode.MULTI_USER_BASIC
and settings.vector_sync_enabled
and settings.enable_offline_access
and settings.enable_background_operations
):
print(
f"DEBUG: Multi-user BasicAuth mode detected, vector_sync={settings.vector_sync_enabled}, offline_access={settings.enable_offline_access}"
f"DEBUG: Multi-user BasicAuth mode detected, vector_sync={settings.vector_sync_enabled}, background_operations={settings.enable_background_operations}"
)
logger.info(
"Multi-user BasicAuth with vector sync - checking for OAuth credentials"
"Multi-user BasicAuth with vector sync - checking for OAuth/app password credentials"
)
# Check for static credentials first
@@ -1098,8 +1202,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"OAuth credentials not configured - attempting Dynamic Client Registration..."
)
import anyio
async def setup_multi_user_basic_dcr():
"""Setup DCR for multi-user BasicAuth background operations."""
# Construct registration endpoint directly from nextcloud_host
@@ -1135,11 +1237,57 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Run DCR synchronously before uvicorn starts
multi_user_basic_oauth_creds = anyio.run(setup_multi_user_basic_dcr)
# Setup OAuth infrastructure for management APIs and background operations
# This creates the UnifiedTokenVerifier needed by management.py and
# RefreshTokenStorage for webhook token persistence
if multi_user_basic_oauth_creds:
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
logger.info(
"Setting up OAuth infrastructure for management APIs (hybrid mode)..."
)
try:
(
multi_user_token_verifier,
multi_user_refresh_storage,
_,
_,
) = anyio.run(
setup_oauth_config_for_multi_user_basic,
settings,
sync_client_id,
sync_client_secret,
)
logger.info(
"✓ OAuth infrastructure setup complete for multi-user BasicAuth hybrid mode"
)
except (httpx.HTTPError, ValueError, KeyError) as e:
# Expected errors during OAuth infrastructure setup:
# - httpx.HTTPError: Network issues, OIDC discovery failures
# - ValueError: Missing required configuration (NEXTCLOUD_HOST)
# - KeyError: Missing required fields in OIDC discovery response
logger.error(f"Failed to setup OAuth infrastructure: {e}")
logger.debug(f"Full traceback:\n{traceback.format_exc()}")
logger.warning(
"Management API will be unavailable. "
"Webhook management from Astrolabe admin UI will not work."
)
# Set to None to indicate failure
multi_user_token_verifier = None
multi_user_refresh_storage = None
except Exception as e:
# Unexpected error - this is a programming error, re-raise it
logger.error(
f"Unexpected error during OAuth infrastructure setup: {e}. "
"This is likely a programming error that should be fixed."
)
raise
# Create MCP server based on detected mode
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
logger.info("Configuring MCP server for OAuth mode")
# Asynchronously get the OAuth configuration
import anyio
(
nextcloud_host,
@@ -1275,13 +1423,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
enable_token_exchange = (
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
)
enable_offline_access_for_tools = os.getenv(
"ENABLE_OFFLINE_ACCESS", "false"
).lower() in (
"true",
"1",
"yes",
)
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
enable_offline_access_for_tools = settings.enable_offline_access
if oauth_enabled and enable_offline_access_for_tools and not enable_token_exchange:
logger.info("Registering OAuth provisioning tools for offline access")
register_oauth_tools(mcp)
@@ -1410,11 +1554,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# For multi-user BasicAuth with offline access, create oauth_context for management APIs
# This allows Astrolabe to use management APIs with OAuth bearer tokens
if settings.enable_multi_user_basic_auth and settings.enable_offline_access:
# Check if we have OAuth credentials from DCR
if multi_user_basic_oauth_creds:
# Check if we have OAuth credentials AND infrastructure from setup
if (
multi_user_basic_oauth_creds
and multi_user_token_verifier is not None
):
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
# Create minimal oauth_context for management API authentication
# Create oauth_context for management API authentication
nextcloud_host_for_context = settings.nextcloud_host
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
@@ -1425,9 +1572,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
oauth_context_dict = {
"storage": basic_auth_storage,
# Use OAuth refresh token storage if available, fallback to basic_auth_storage
"storage": multi_user_refresh_storage or basic_auth_storage,
"oauth_client": None, # Not needed for management APIs
"token_verifier": None, # Will be set when token broker is created
"token_verifier": multi_user_token_verifier, # FIXED: Now has real verifier!
"config": {
"mcp_server_url": mcp_server_url,
"discovery_url": discovery_url,
@@ -1441,7 +1589,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
}
app.state.oauth_context = oauth_context_dict
logger.info(
f"OAuth context initialized for management APIs (multi-user BasicAuth, client_id={sync_client_id[:16]}...)"
f"OAuth context initialized for management APIs (hybrid mode, client_id={sync_client_id[:16]}...)"
)
elif multi_user_basic_oauth_creds and multi_user_token_verifier is None:
logger.warning(
"OAuth infrastructure setup failed - management API will be unavailable. "
"This is expected if OIDC discovery failed or token verifier creation failed. "
"Webhook management from Astrolabe admin UI will not work."
)
else:
logger.warning(
"OAuth credentials not available - management API will be unavailable. "
"This is expected if DCR failed or static credentials were not provided. "
"Webhook management from Astrolabe admin UI will not work."
)
# Also share with browser_app for webhook routes
@@ -1465,7 +1625,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Start background vector sync tasks (ADR-007)
# Scanner runs at server-level (once), not per-session
import anyio as anyio_module
# Re-use settings from outer scope (already validated)
# Note: enable_offline_access_for_sync, encryption_key, and refresh_token_storage
@@ -1505,11 +1664,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
) from e
# Initialize shared state
send_stream, receive_stream = anyio_module.create_memory_object_stream(
send_stream, receive_stream = anyio.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size
)
shutdown_event = anyio_module.Event()
scanner_wake_event = anyio_module.Event()
shutdown_event = anyio.Event()
scanner_wake_event = anyio.Event()
# Store in app state for access from routes (ADR-007)
app.state.document_send_stream = send_stream
@@ -1536,7 +1695,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
break
# Start background tasks using anyio TaskGroup
async with anyio_module.create_task_group() as tg:
async with anyio.create_task_group() as tg:
# Start scanner task
await tg.start(
scanner_task,
@@ -1578,10 +1737,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
elif (
settings.vector_sync_enabled
and (oauth_enabled or settings.enable_multi_user_basic_auth)
and settings.enable_offline_access
and settings.enable_background_operations
):
# OAuth mode with offline access - multi-user sync
# Also used for multi-user BasicAuth mode (client auth is BasicAuth, background sync uses app passwords)
# OAuth mode with background operations - multi-user sync
# Also used for multi-user BasicAuth mode (client auth is BasicAuth, background sync uses app passwords or OAuth)
mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode"
logger.info(f"Starting background vector sync tasks for {mode_desc}")
@@ -1667,11 +1826,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
) from e
# Initialize shared state
send_stream, receive_stream = anyio_module.create_memory_object_stream(
send_stream, receive_stream = anyio.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size
)
shutdown_event = anyio_module.Event()
scanner_wake_event = anyio_module.Event()
shutdown_event = anyio.Event()
scanner_wake_event = anyio.Event()
# User state tracking for user manager
user_states: dict = {}
@@ -1702,19 +1861,25 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
break
# Determine authentication mode for background sync
# Multi-user BasicAuth: use app passwords via Astrolabe (NOT OAuth)
# OAuth mode: use OAuth refresh tokens (NOT app passwords)
use_basic_auth = not oauth_enabled
# Start background tasks using anyio TaskGroup
async with anyio_module.create_task_group() as tg:
async with anyio.create_task_group() as tg:
# Start user manager task (supervises per-user scanners)
await tg.start(
user_manager_task,
send_stream,
shutdown_event,
scanner_wake_event,
token_broker,
token_broker if not use_basic_auth else None,
token_storage, # Use token_storage (works for both OAuth and multi-user BasicAuth)
nextcloud_host_for_sync,
user_states,
tg,
use_basic_auth, # Pass as positional arg (before task_status)
)
# Start processor pool (each gets a cloned receive stream)
@@ -1724,8 +1889,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
i,
receive_stream.clone(),
shutdown_event,
token_broker,
token_broker if not use_basic_auth else None,
nextcloud_host_for_sync,
use_basic_auth, # Pass as positional arg (before task_status)
)
logger.info(
@@ -1908,7 +2074,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
This is a temporary endpoint for testing webhook schemas and payloads.
It logs the full payload and returns 200 OK immediately.
"""
import json
try:
payload = await request.json()
@@ -2028,7 +2193,21 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
if oauth_enabled:
# Determine if OAuth provisioning is available
# This is true for:
# 1. OAuth modes (primary auth method for MCP operations)
# 2. Multi-user BasicAuth with offline access (hybrid mode)
oauth_provisioning_available = oauth_enabled or (
mode == AuthMode.MULTI_USER_BASIC
and settings.enable_offline_access
and multi_user_token_verifier is not None # Ensure OAuth setup succeeded
)
if oauth_provisioning_available:
logger.info(
f"OAuth provisioning routes enabled for mode: {mode.value} "
f"(oauth_enabled={oauth_enabled}, hybrid_mode={not oauth_enabled})"
)
# Import OAuth routes (ADR-004 Progressive Consent)
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
@@ -2091,10 +2270,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
)
# Add OAuth login routes (ADR-004 Progressive Consent Flow 1)
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
# Add unified OAuth callback endpoint supporting both flows
from nextcloud_mcp_server.auth.oauth_routes import (
oauth_authorize_nextcloud,
@@ -2124,11 +2299,21 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
)
logger.info(
"OAuth resource provisioning routes enabled: /oauth/authorize-nextcloud, /oauth/callback-nextcloud (Flow 2, legacy)"
"OAuth resource provisioning routes enabled: /oauth/authorize-nextcloud, /oauth/callback-nextcloud (Flow 2)"
)
# Add browser OAuth login routes (OAuth mode only)
# Add OAuth Flow 1 routes (MCP client login) - ONLY for OAuth modes
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
if oauth_enabled:
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
# Add browser OAuth login routes for Management API access
# Available in OAuth modes AND multi-user BasicAuth with offline access
# (hybrid mode). Separate from MCP tool auth - Management API uses OAuth
if oauth_provisioning_available:
from nextcloud_mcp_server.auth.browser_oauth_routes import (
oauth_login,
oauth_login_callback,
@@ -2281,8 +2466,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Starlette caches the body internally, so it's safe to read here
body = await request.body()
try:
import json
data = json.loads(body)
# Check if this is an initialize request
if data.get("method") == "initialize":
@@ -8,6 +8,7 @@ import hashlib
import logging
import os
import secrets
import time
from base64 import urlsafe_b64encode
from urllib.parse import urlencode
@@ -301,25 +302,6 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
discovery = response.json()
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 = {
"grant_type": "authorization_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_at = None
if refresh_expires_in:
import time
refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
+1 -1
View File
@@ -8,6 +8,7 @@ Handles OAuth flows with Keycloak as the identity provider, including:
- Integration with RefreshTokenStorage
"""
import base64
import hashlib
import logging
import os
@@ -155,7 +156,6 @@ class KeycloakOAuthClient:
Returns:
Tuple of (code_verifier, code_challenge)
"""
import base64
# Generate code verifier (43-128 characters)
code_verifier = secrets.token_urlsafe(32)
+1 -2
View File
@@ -23,6 +23,7 @@ import hashlib
import logging
import os
import secrets
import time
from base64 import urlsafe_b64encode
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_at = None
if refresh_expires_in:
import time
refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(f" refresh_expires_in: {refresh_expires_in}s")
logger.info(f" refresh_expires_at: {refresh_expires_at}")
@@ -9,6 +9,7 @@ import functools
import logging
from typing import Callable
import jwt
from mcp.server.fastmcp import Context
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
@@ -78,8 +79,6 @@ def require_provisioning(func: Callable) -> Callable:
user_id = None
if hasattr(ctx, "authorization") and ctx.authorization:
try:
import jwt
token = ctx.authorization.token
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub")
@@ -163,8 +162,6 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
# Get user_id from authorization token
user_id = None
if hasattr(ctx, "authorization") and ctx.authorization:
import jwt
token = ctx.authorization.token
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub")
@@ -1,7 +1,6 @@
"""Scope-based authorization for MCP tools."""
import logging
import os
from functools import wraps
from typing import Any, Callable
@@ -131,9 +130,12 @@ def require_scopes(*required_scopes: str):
required_scopes_set = set(required_scopes)
# Check if offline access is enabled
enable_offline_access = (
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
)
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
# 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
if enable_offline_access:
+1 -1
View File
@@ -28,6 +28,7 @@ Sensitive data (tokens, secrets) is encrypted at rest using Fernet symmetric enc
import json
import logging
import os
import socket
import time
from pathlib import Path
from typing import Any, Optional
@@ -830,7 +831,6 @@ class RefreshTokenStorage:
resource_id: Resource identifier
auth_method: Authentication method used
"""
import socket
hostname = socket.gethostname()
timestamp = int(time.time())
+2 -33
View File
@@ -168,37 +168,6 @@ class TokenBrokerService:
self._oidc_config = response.json()
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]:
"""
Get a valid Nextcloud access token for the user.
@@ -407,7 +376,7 @@ class TokenBrokerService:
Tuple of (access_token, expires_in_seconds)
"""
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()
@@ -477,7 +446,7 @@ class TokenBrokerService:
Tuple of (access_token, expires_in_seconds)
"""
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()
+169 -15
View File
@@ -117,6 +117,71 @@ class UnifiedTokenVerifier(TokenVerifier):
# Both modes do the same validation (MCP audience only)
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:
"""
Validate token has MCP audience.
@@ -186,6 +251,78 @@ class UnifiedTokenVerifier(TokenVerifier):
record_oauth_token_validation(validation_method, "error")
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:
"""
Check if token has MCP audience.
@@ -230,12 +367,15 @@ class UnifiedTokenVerifier(TokenVerifier):
"""
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.
Args:
token: JWT token to verify
skip_issuer_check: If True, skip issuer validation (for management API tokens)
Returns:
Decoded payload if valid, None if invalid
@@ -248,25 +388,22 @@ class UnifiedTokenVerifier(TokenVerifier):
# Verify and decode JWT
# 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(
token,
signing_key.key,
algorithms=["RS256"],
issuer=(
self.settings.oidc_issuer
if hasattr(self.settings, "oidc_issuer")
else None
),
issuer=(self.settings.oidc_issuer if should_verify_issuer else None),
options={
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
"verify_iss": (
True
if hasattr(self.settings, "oidc_issuer")
and self.settings.oidc_issuer
else False
),
"verify_iss": should_verify_issuer,
"verify_aud": False, # We handle audience validation separately
},
)
@@ -358,6 +495,24 @@ class UnifiedTokenVerifier(TokenVerifier):
token: The bearer token
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:
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")
exp = int(time.time() + self.cache_ttl)
# Cache the result
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Cache the result with the provided key
userinfo = {
"sub": username,
"scope": scope_string,
**{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(
token=token,
+1 -2
View File
@@ -9,6 +9,7 @@ For OAuth mode: Requires browser-based OAuth login to establish session.
import logging
import os
import traceback
from pathlib import Path
from typing import Any
@@ -385,8 +386,6 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
return user_context
except Exception as e:
import traceback
logger.error(f"Error retrieving user info: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
return {
+1 -2
View File
@@ -15,6 +15,7 @@ import logging
import time
from pathlib import Path
import anyio
import numpy as np
from jinja2 import Environment, FileSystemLoader
from starlette.authentication import requires
@@ -396,8 +397,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
coords = pca.fit_transform(vectors)
return coords, pca
import anyio
with trace_operation(
"vector_viz.pca_compute",
attributes={
+15 -20
View File
@@ -285,28 +285,23 @@ class DeckClient(BaseNextcloudClient):
archived: Optional[bool] = None,
done: Optional[str] = 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)
json_data = {}
if title is not None:
json_data["title"] = title
if description is not None:
json_data["description"] = description
# Type is required by the API, use provided or keep current
json_data["type"] = type if type is not None else current_card.type
# Owner is required by the API, use provided or keep current
json_data["owner"] = (
owner
if owner is not None
else (
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
)
)
# Build payload with required fields always included
json_data = {
# Title is required by the API
"title": title if title is not None else current_card.title,
# Type is required by the API
"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": owner if owner is not None else current_card.owner,
# Description must be sent to preserve it (PUT clears omitted fields)
"description": description
if description is not None
else (current_card.description or ""),
}
if order is not None:
json_data["order"] = order
if duedate is not None:
+14 -1
View File
@@ -1,6 +1,7 @@
import logging
import logging.config
import os
import socket
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional
@@ -337,7 +338,6 @@ class Settings:
Returns:
Collection name string
"""
import socket
# Use explicit override if user configured non-default value
if self.qdrant_collection != "nextcloud_content":
@@ -356,6 +356,19 @@ class Settings:
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.
+1 -2
View File
@@ -9,6 +9,7 @@ See ADR-020 for detailed architecture and deployment mode documentation.
"""
import logging
import os
from dataclasses import dataclass
from enum import Enum
@@ -240,8 +241,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
Raises:
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
"""
import logging
import os
logger = logging.getLogger(__name__)
@@ -6,6 +6,8 @@ import tempfile
from collections.abc import Awaitable, Callable
from typing import Any, Optional
import anyio
# NOTE: Do NOT call pymupdf.layout.activate() here!
# It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True,
# causing it to return a string instead of a list[dict].
@@ -95,7 +97,6 @@ class PyMuPDFProcessor(DocumentProcessor):
Raises:
ProcessorError: If PDF processing fails
"""
import anyio
try:
if progress_callback:
@@ -3,6 +3,7 @@
import logging
from typing import Any
import anyio
from fastembed import SparseTextEmbedding
logger = logging.getLogger(__name__)
@@ -67,7 +68,6 @@ class BM25SparseEmbeddingProvider:
Returns:
Dictionary with 'indices' and 'values' keys for Qdrant sparse vector
"""
import anyio
# Run CPU-bound BM25 encoding in thread pool
return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined]
@@ -82,7 +82,6 @@ class BM25SparseEmbeddingProvider:
Returns:
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
sparse_embeddings = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
+1 -1
View File
@@ -6,6 +6,7 @@ provides CLI integration.
"""
import logging
import sqlite3
from pathlib import Path
from alembic.config import Config
@@ -98,7 +99,6 @@ def get_current_revision(database_path: str | Path | None = None) -> str | None:
Returns:
Current revision ID or None if not versioned
"""
import sqlite3
if database_path is None:
database_path = "/app/data/tokens.db"
@@ -14,7 +14,9 @@ and resource usage. Metrics are organized by category:
- External Dependency Health Metrics
"""
import functools
import logging
import time
from prometheus_client import (
Counter,
@@ -423,8 +425,6 @@ def instrument_tool(func):
Returns:
Wrapped function with metrics and tracing instrumentation
"""
import functools
import time
from nextcloud_mcp_server.observability.tracing import trace_operation
+7 -7
View File
@@ -1,9 +1,16 @@
"""Base interfaces and data structures for search algorithms."""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
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
class NextcloudClientProtocol(Protocol):
@@ -78,13 +85,6 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
>>> if "note" in types:
... # 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__)
settings = get_settings()
+3 -2
View File
@@ -7,6 +7,9 @@ position markers for better visualization and understanding of search results.
import logging
from dataclasses import dataclass
import pymupdf
import pymupdf4llm
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
@@ -549,8 +552,6 @@ async def _fetch_document_text(
# Extract text from PDF using PyMuPDF
# IMPORTANT: Use pymupdf4llm.to_markdown() to match indexing extraction
# This ensures character offsets align between indexed chunks and retrieval
import pymupdf
import pymupdf4llm
logger.debug(f"Extracting text from PDF: {file_path}")
pdf_doc = pymupdf.open(stream=file_content, filetype="pdf")
@@ -10,6 +10,9 @@ varies between indexing and rendering.
import logging
import re
import shutil
import tempfile
from pathlib import Path
from typing import Optional
import pymupdf
@@ -77,8 +80,6 @@ class PDFHighlighter:
Tuple of (full_text, page_boundaries) where page_boundaries is a list of:
{"page": 1, "start_offset": 0, "end_offset": 1234}
"""
import tempfile
from pathlib import Path
page_boundaries = []
text_parts = []
@@ -110,7 +111,6 @@ class PDFHighlighter:
full_text = "".join(text_parts)
# Clean up temp directory and extracted images
import shutil
try:
shutil.rmtree(temp_dir)
@@ -590,8 +590,6 @@ class PDFHighlighter:
Returns:
Tuple of (png_bytes, page_number, highlight_count) or None if failed
"""
import tempfile
from pathlib import Path
temp_pdf_path = None
try:
+14 -13
View File
@@ -12,6 +12,7 @@ from typing import Optional
from urllib.parse import urlencode
import httpx
import jwt
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
@@ -53,8 +54,6 @@ async def extract_user_id_from_token(ctx: Context) -> str:
# Try JWT decode first
if is_jwt:
try:
import jwt
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub", "unknown")
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
@@ -303,16 +302,17 @@ async def provision_nextcloud_access(
),
)
# Get configuration
enable_offline_access = (
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
)
if not enable_offline_access:
# Get configuration using settings (handles both ENABLE_BACKGROUND_OPERATIONS
# and ENABLE_OFFLINE_ACCESS environment variables)
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if not settings.enable_offline_access:
return ProvisioningResult(
success=False,
message=(
"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)
# Not logged in - generate OAuth URL for Flow 2
enable_offline_access = (
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
)
if not enable_offline_access:
# Use settings (handles both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS)
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if not settings.enable_offline_access:
return (
"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
+1 -1
View File
@@ -1,6 +1,7 @@
"""Semantic search MCP tools using vector database."""
import logging
import os
import anyio
from httpx import RequestError
@@ -656,7 +657,6 @@ def configure_semantic_tools(mcp: FastMCP):
This is useful for determining when vector indexing is complete
after creating or updating content across all indexed apps.
"""
import os
# Check if vector sync is enabled
vector_sync_enabled = (
+1 -3
View File
@@ -1,3 +1,4 @@
import base64
import logging
from mcp.server.fastmcp import Context, FastMCP
@@ -120,7 +121,6 @@ def configure_webdav_tools(mcp: FastMCP):
pass
# For binary files, return metadata and base64 encoded content
import base64
return {
"path": path,
@@ -156,8 +156,6 @@ def configure_webdav_tools(mcp: FastMCP):
# Handle base64 encoded content
if content_type and "base64" in content_type.lower():
import base64
content_bytes = base64.b64decode(content)
content_type = content_type.replace(";base64", "")
else:
@@ -3,6 +3,7 @@
import logging
from dataclasses import dataclass
import anyio
from langchain_text_splitters import RecursiveCharacterTextSplitter
logger = logging.getLogger(__name__)
@@ -68,7 +69,6 @@ class DocumentChunker:
Returns:
List of chunks with their character positions in the original content
"""
import anyio
# Handle empty content - return single empty chunk for backward compatibility
if not content:
@@ -1,6 +1,7 @@
"""HTML to Markdown conversion utilities for vector sync."""
import logging
import re
from markdownify import markdownify as md
@@ -43,7 +44,6 @@ def html_to_markdown(html_content: str | None) -> str:
except Exception as e:
logger.warning(f"Failed to convert HTML to Markdown: {e}")
# Fallback: strip all HTML tags as a last resort
import re
text = re.sub(r"<[^>]+>", " ", html_content)
return " ".join(text.split()) # Normalize whitespace
+174 -81
View File
@@ -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
with ENABLE_OFFLINE_ACCESS=true:
- User Manager: Monitors RefreshTokenStorage for user changes
Manages background vector sync for multi-user deployments:
- User Manager: Monitors storage for user changes
- Per-User Scanners: One scanner task per provisioned user
- Shared Processor Pool: Processes documents from all users
Supports dual credential types for background sync:
- App passwords (interim solution, works today)
- OAuth refresh tokens (future, when Nextcloud supports OAuth for app APIs)
Authentication strategies are mutually exclusive by deployment mode:
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
@@ -59,16 +68,64 @@ class UserSyncState:
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,
token_broker: "TokenBrokerService",
nextcloud_host: str,
) -> NextcloudClient:
"""Get an authenticated NextcloudClient for a user.
"""Get an authenticated NextcloudClient using OAuth refresh token.
Supports dual credential types with priority:
1. App password from Astrolabe (works today with BasicAuth)
2. OAuth refresh token from storage (for future when OAuth fully supported)
For OAuth deployments with external IdP where users provision via
browser OAuth flow. App passwords are NOT used in this mode.
Args:
user_id: User identifier
@@ -76,45 +133,19 @@ async def get_user_client(
nextcloud_host: Nextcloud base URL
Returns:
Authenticated NextcloudClient
Authenticated NextcloudClient with Bearer token
Raises:
NotProvisionedError: If user has not provisioned offline access
"""
settings = get_settings()
# Try app password first (interim solution, works today)
if settings.oidc_client_id and settings.oidc_client_secret:
try:
astrolabe = AstrolabeClient(
nextcloud_host=nextcloud_host,
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
)
app_password = await astrolabe.get_user_app_password(user_id)
if app_password:
logger.info(
f"Using app password for background sync: {user_id} "
f"(credential_type=app_password)"
)
return NextcloudClient(
base_url=nextcloud_host,
username=user_id,
auth=BasicAuth(user_id, app_password),
)
except Exception as e:
logger.debug(f"App password not available for {user_id}: {e}")
# Fall back to OAuth refresh token
logger.info(
f"Using OAuth refresh token for background sync: {user_id} "
f"(credential_type=refresh_token)"
)
token = await token_broker.get_background_token(user_id, VECTOR_SYNC_SCOPES)
if not token:
raise NotProvisionedError(f"User {user_id} has not provisioned offline access")
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(
base_url=nextcloud_host,
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(
user_id: str,
send_stream: MemoryObjectSendStream[DocumentTask],
shutdown_event: anyio.Event,
wake_event: anyio.Event,
token_broker: "TokenBrokerService",
token_broker: "TokenBrokerService | None",
nextcloud_host: str,
*,
use_basic_auth: bool = False,
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
) -> 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:
user_id: User to scan
send_stream: Stream to send changed documents to processors
shutdown_event: Event signaling shutdown
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
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
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()
task_status.started()
@@ -153,8 +220,10 @@ async def user_scanner_task(
while not shutdown_event.is_set():
nc_client = None
try:
# Get fresh token for this scan cycle
nc_client = await get_user_client(user_id, token_broker, nextcloud_host)
# Get fresh credentials for this scan cycle
nc_client = await get_user_client(
user_id, token_broker, nextcloud_host, use_basic_auth=use_basic_auth
)
# Scan user's documents
await scan_user_documents(
@@ -165,12 +234,14 @@ async def user_scanner_task(
except NotProvisionedError:
logger.warning(
f"[OAuth] User {user_id} no longer provisioned, stopping scanner"
f"[{mode_label}] User {user_id} no longer provisioned, stopping scanner"
)
break
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:
if nc_client:
@@ -183,33 +254,36 @@ async def user_scanner_task(
except anyio.get_cancelled_exc_class():
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,
receive_stream: MemoryObjectReceiveStream[DocumentTask],
shutdown_event: anyio.Event,
token_broker: "TokenBrokerService",
token_broker: "TokenBrokerService | None",
nextcloud_host: str,
use_basic_auth: bool = False,
*,
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
) -> 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:
worker_id: Worker identifier for logging
receive_stream: Stream to receive documents from
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
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
task_status: Status object for signaling task readiness
"""
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()
while not shutdown_event.is_set():
@@ -220,9 +294,12 @@ async def oauth_processor_task(
with anyio.fail_after(1.0):
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(
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
@@ -232,13 +309,13 @@ async def oauth_processor_task(
continue
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
except NotProvisionedError:
if doc_task:
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}"
)
continue
@@ -246,18 +323,24 @@ async def oauth_processor_task(
except Exception as e:
if doc_task:
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}",
exc_info=True,
)
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:
if nc_client:
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(
@@ -266,9 +349,10 @@ async def _run_user_scanner_with_scope(
send_stream: MemoryObjectSendStream[DocumentTask],
shutdown_event: anyio.Event,
wake_event: anyio.Event,
token_broker: "TokenBrokerService",
token_broker: "TokenBrokerService | None",
nextcloud_host: str,
user_states: dict[str, UserSyncState],
use_basic_auth: bool = False,
) -> None:
"""Wrapper to run scanner with cancellation scope.
@@ -284,6 +368,7 @@ async def _run_user_scanner_with_scope(
wake_event=wake_event,
token_broker=token_broker,
nextcloud_host=nextcloud_host,
use_basic_auth=use_basic_auth,
)
finally:
# Clean up on exit
@@ -296,35 +381,40 @@ async def user_manager_task(
send_stream: MemoryObjectSendStream[DocumentTask],
shutdown_event: anyio.Event,
wake_event: anyio.Event,
token_broker: "TokenBrokerService",
token_broker: "TokenBrokerService | None",
refresh_token_storage: "RefreshTokenStorage",
nextcloud_host: str,
user_states: dict[str, UserSyncState],
tg: TaskGroup,
use_basic_auth: bool = False,
*,
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
) -> None:
"""Supervisor task that manages per-user scanners.
Periodically polls RefreshTokenStorage to detect:
- New users who have provisioned offline access -> start scanner
Periodically polls storage to detect:
- New users who have provisioned access -> start scanner
- Users who have revoked access -> cancel their scanner
Args:
send_stream: Stream to send documents to processors
shutdown_event: Event signaling shutdown
wake_event: Event to wake scanners for immediate scan
token_broker: Token broker for obtaining access tokens
refresh_token_storage: Storage for refresh tokens
token_broker: Token broker for OAuth mode (None for BasicAuth mode)
refresh_token_storage: Storage for tracking provisioned users
nextcloud_host: Nextcloud base URL
user_states: Shared dict tracking active user scanners
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
"""
settings = get_settings()
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()
while not shutdown_event.is_set():
@@ -337,7 +427,7 @@ async def user_manager_task(
new_users = provisioned_users - active_users
for user_id in new_users:
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()
user_states[user_id] = UserSyncState(
@@ -356,24 +446,27 @@ async def user_manager_task(
token_broker,
nextcloud_host,
user_states,
use_basic_auth, # Positional after user_states
)
# Cancel scanners for revoked users
revoked_users = active_users - provisioned_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)
if state:
state.cancel_scope.cancel()
# Note: state will be removed by _run_user_scanner_with_scope on exit
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:
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:
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
try:
@@ -384,9 +477,9 @@ async def user_manager_task(
# Cancel all remaining scanners on shutdown
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()):
state.cancel_scope.cancel()
logger.info("[OAuth] User manager stopped")
logger.info(f"[{mode_label}] User manager stopped")
+1 -2
View File
@@ -3,6 +3,7 @@
Processes documents from stream: fetches content, generates embeddings, stores in Qdrant.
"""
import base64
import logging
import time
import uuid
@@ -585,8 +586,6 @@ async def _index_document(
"vector_sync.pdf_size": len(content_bytes),
},
):
import base64
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
# Build chunk data for batch processing
+1 -1
View File
@@ -5,6 +5,7 @@ Periodically scans enabled users' content and queues changed documents for proce
import logging
import os
import random
import time
from dataclasses import dataclass
@@ -167,7 +168,6 @@ async def scan_user_documents(
nc_client: Authenticated Nextcloud client
initial_sync: If True, send all documents (first-time sync)
"""
import random
scan_id = random.randint(1000, 9999)
logger.info(
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.57.0"
version = "0.60.3"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"}
@@ -64,7 +64,7 @@ Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHAN
[tool.pytest.ini_options]
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_level = "ERROR"
log_level = "ERROR"
+3 -2
View File
@@ -1,3 +1,5 @@
import json
import httpx
# ============================================================================
@@ -22,14 +24,13 @@ def create_mock_response(
Returns:
Mock httpx.Response object
"""
import json as json_module
if headers is None:
headers = {}
# If json_data is provided, serialize it to content
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")
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"
+10 -25
View File
@@ -1,7 +1,17 @@
import base64
import hashlib
import json
import logging
import os
import re
import secrets
import subprocess
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, AsyncGenerator
from urllib.parse import parse_qs, quote, urlparse
import anyio
import httpx
@@ -257,7 +267,6 @@ async def nc_mcp_basic_auth_client(
Uses anyio pytest plugin for proper async fixture handling.
"""
import base64
credentials = base64.b64encode(b"admin:admin").decode("utf-8")
auth_header = f"Basic {credentials}"
@@ -342,7 +351,6 @@ async def nc_mcp_oauth_client_with_elicitation(
logger.info(f" Schema: {params.schema}")
# Extract OAuth URL from elicitation message
import re
url_pattern = r"https?://[^\s]+"
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"
# )
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
# This allows multiple concurrent OAuth flows
auth_states = {}
@@ -1758,9 +1762,6 @@ async def playwright_oauth_token(
- Browser fixture provided by pytest-playwright-asyncio
- See: https://playwright.dev/python/docs/test-runners
"""
import secrets
import time
from urllib.parse import quote
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
username = os.getenv("NEXTCLOUD_USERNAME")
@@ -2047,9 +2048,6 @@ async def _get_oauth_token_with_scopes(
Returns:
OAuth access token string with requested scopes
"""
import secrets
import time
from urllib.parse import quote
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
username = os.getenv("NEXTCLOUD_USERNAME")
@@ -2417,9 +2415,6 @@ async def _get_oauth_token_for_user(
Returns:
OAuth access token string
"""
import secrets
import time
from urllib.parse import quote
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
@@ -2560,7 +2555,6 @@ async def all_oauth_tokens(
Now uses the real callback server with state parameters for reliable
concurrent token acquisition without race conditions.
"""
import time
# Get auth_states dict from callback server
auth_states, callback_url = oauth_callback_server
@@ -2711,7 +2705,6 @@ async def test_user(nc_client: NextcloudClient):
user_config = test_user
await nc_client.users.create_user(**user_config)
"""
import uuid
# Generate unique user ID to avoid conflicts
userid = f"testuser_{uuid.uuid4().hex[:8]}"
@@ -2747,7 +2740,6 @@ async def test_group(nc_client: NextcloudClient):
Returns the group ID.
"""
import uuid
# Generate unique group ID to avoid conflicts
groupid = f"testgroup_{uuid.uuid4().hex[:8]}"
@@ -2882,11 +2874,6 @@ async def _get_keycloak_oauth_token(
Returns:
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
auth_states, _ = oauth_callback_server
@@ -3252,8 +3239,6 @@ async def configure_astrolabe_for_mcp_server(nc_client):
- mcp_server_public_url: Public URL for OAuth token audience validation
- client_id: Optional OAuth client ID (default: "nextcloudMcpServerUIPublicClient")
"""
import json
import subprocess
async def _configure(
mcp_server_internal_url: str,
@@ -1,6 +1,7 @@
"""Integration tests for document processing with progress notifications."""
import io
import os
import pytest
from PIL import Image
@@ -13,7 +14,6 @@ class TestDocumentProcessingProgress:
async def test_unstructured_processor_with_progress_callback(self, nc_client):
"""Test that UnstructuredProcessor calls progress callback during processing."""
import os
# Skip if unstructured is not enabled
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
@@ -71,7 +71,6 @@ class TestDocumentProcessingProgress:
self, nc_mcp_client, nc_client
):
"""Test that reading a document via WebDAV MCP tool sends progress notifications."""
import os
# Skip if document processing is not enabled
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):
"""Test that processing works without progress callback (backward compatibility)."""
import os
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
pytest.skip("Unstructured processor not enabled")
+92
View File
@@ -4,6 +4,12 @@ This conftest.py provides hooks and fixtures specific to integration tests,
including the --provider flag for RAG tests.
"""
import logging
import pytest
logger = logging.getLogger(__name__)
# Valid provider names
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
@@ -24,3 +30,89 @@ def pytest_configure(config):
config.addinivalue_line(
"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.
Tests the complete flow:
Tests the complete flow for multi-user BasicAuth mode:
1. User stores app password via Astrolabe API
2. MCP server retrieves it via OAuth client credentials
3. Background sync uses it to access Nextcloud
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
from httpx import BasicAuth
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.oauth_sync import get_user_client
from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError,
get_user_client,
get_user_client_basic_auth,
get_user_client_oauth,
)
@pytest.mark.integration
@@ -77,9 +84,20 @@ async def test_get_user_app_password_returns_none_for_unconfigured_user():
@pytest.mark.integration
async def test_dual_credential_support_in_background_sync(mocker):
"""Test that background sync tries app password first, then refresh token."""
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
async def test_basic_auth_mode_uses_app_password_only(mocker):
"""Test that BasicAuth mode uses ONLY app passwords, NOT OAuth tokens.
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_astrolabe = mocker.AsyncMock()
@@ -90,35 +108,36 @@ async def test_dual_credential_support_in_background_sync(mocker):
return_value=mock_astrolabe,
)
# Mock TokenBrokerService (shouldn't be called if app password works)
mock_token_broker = mocker.MagicMock(spec=TokenBrokerService)
# Call get_user_client in BasicAuth mode
_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
try:
_client = await get_user_client(
user_id="test_user",
token_broker=mock_token_broker,
nextcloud_host="http://localhost:8080",
)
# Verify app password was requested
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
# Verify app password was requested
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
# Verify token broker was NOT called (app password took priority)
mock_token_broker.get_background_token.assert_not_called()
# Verify client uses BasicAuth
assert _client.auth is not None
assert isinstance(_client.auth, BasicAuth)
except Exception:
# May fail in test environment, but we verified the priority logic
pass
# Verify client was created successfully with correct username
assert _client is not None
assert _client.username == "test_user"
@pytest.mark.integration
async def test_background_sync_falls_back_to_refresh_token(mocker):
"""Test that background sync falls back to refresh token if no app password."""
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
async def test_basic_auth_mode_raises_error_without_app_password(mocker):
"""Test that BasicAuth mode raises NotProvisionedError if no app password.
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_astrolabe = mocker.AsyncMock()
@@ -129,23 +148,131 @@ async def test_background_sync_falls_back_to_refresh_token(mocker):
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_token_broker = mocker.AsyncMock(spec=TokenBrokerService)
mock_token_broker.get_background_token.return_value = "test-access-token"
# Call get_user_client - should fall back to refresh token
try:
_client = await get_user_client(
# Call get_user_client in OAuth mode
_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",
token_broker=mock_token_broker,
nextcloud_host="http://localhost:8080",
use_basic_auth=False,
)
# Verify app password was attempted first
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
# Verify error message mentions OAuth provisioning
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()
except Exception:
# May fail in test environment, but we verified the fallback logic
pass
@pytest.mark.integration
async def test_get_user_client_basic_auth_function(mocker):
"""Test the dedicated get_user_client_basic_auth function."""
# 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
)
@@ -13,6 +13,8 @@ app password entry → background sync activation → database verification.
"""
import logging
import re
import subprocess
import anyio
import pytest
@@ -151,7 +153,6 @@ async def generate_app_password(
)
# Validate password format before returning
import re
if not re.match(
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$",
@@ -350,7 +351,6 @@ async def verify_app_password_created(username: str) -> bool:
# Query the database to check for background sync credentials
# Astrolabe stores app passwords in oc_preferences, not oc_authtoken
import subprocess
query = f"""
SELECT userid, configkey, configvalue
@@ -559,3 +559,259 @@ async def test_multi_user_astrolabe_background_sync_enablement(
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()
+38 -11
View File
@@ -4,18 +4,26 @@ Tests that BasicAuth credentials are extracted from request headers
and passed through to Nextcloud APIs without storage (stateless).
"""
import json
import pytest
@pytest.mark.integration
async def test_basic_auth_pass_through_notes_list(nc_mcp_basic_auth_client):
"""Test BasicAuth pass-through with notes list tool."""
async def test_basic_auth_pass_through_notes_search(nc_mcp_basic_auth_client):
"""Test BasicAuth pass-through with notes search tool."""
# 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
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
@@ -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."""
# Create a note using BasicAuth
response = await nc_mcp_basic_auth_client.call_tool(
"nc_notes_create",
"nc_notes_create_note",
{
"title": "BasicAuth Test Note",
"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.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
async def test_basic_auth_pass_through_search(nc_mcp_basic_auth_client):
"""Test BasicAuth pass-through with search tool."""
# Search notes using BasicAuth
async def test_basic_auth_pass_through_get_note(nc_mcp_basic_auth_client):
"""Test BasicAuth pass-through with get note tool."""
# 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(
"nc_notes_search", {"query": "BasicAuth"}
"nc_notes_get_note", {"note_id": note_id}
)
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
import pytest
from qdrant_client.models import VectorParams
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
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)
async def reset_singleton():
"""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
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
@@ -127,7 +136,7 @@ async def test_existing_collection_reused(monkeypatch):
# Verify dimensions unchanged
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
@@ -164,7 +173,7 @@ async def test_dimension_mismatch_detected(monkeypatch, tmp_path):
# Verify collection created
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
await client1.close()
@@ -248,12 +257,10 @@ async def test_collection_name_generation(monkeypatch):
mock_settings = Settings(
qdrant_location=":memory:",
ollama_embedding_model="test-model",
otel_service_name="test-deployment",
vector_sync_enabled=False,
)
# Mock deployment ID
monkeypatch.setenv("MCP_DEPLOYMENT_ID", "test-deployment")
monkeypatch.setattr(
"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
assert collection_info.config.params.vectors.distance == Distance.COSINE
assert get_vector_params(collection_info).distance == Distance.COSINE
+15 -5
View File
@@ -51,6 +51,14 @@ logger = logging.getLogger(__name__)
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(
provider: Provider,
ground_truth: str,
@@ -116,6 +124,8 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
Environment Variables:
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)
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:
content = result.structuredContent or {}
content = json.loads(result.content[0].text) if result.content else {}
indexed = content.get("indexed_count", 0)
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}"
data = result.structuredContent
data = json.loads(result.content[0].text)
# Verify we got results
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}"
data = result.structuredContent
data = json.loads(result.content[0].text)
# Verify response structure
assert data["success"] is True
@@ -369,7 +379,7 @@ async def test_retrieval_quality_all_queries(
)
assert result.isError is False
data = result.structuredContent
data = json.loads(result.content[0].text)
assert data["total_found"] >= min_expected_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
data = result.structuredContent
data = json.loads(result.content[0].text)
# Should have few or no high-scoring results
# Low score threshold means we might get some results, but they should be low quality
+34 -18
View File
@@ -13,14 +13,24 @@ Note: These tests require VECTOR_SYNC_ENABLED=true and a configured
vector database with indexed test data.
"""
import json
from unittest.mock import MagicMock
import anyio
import pytest
from mcp.types import CreateMessageResult, TextContent
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
def mock_sampling_result():
"""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
5. Verify response contains generated answer and sources
"""
await require_vector_sync_tools(nc_mcp_client)
# Get initial indexed count before creating note
import asyncio
initial_sync = await nc_mcp_client.call_tool(
"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}")
# 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(
"nc_get_vector_sync_status", arguments={}
)
status_data = sync_status.structuredContent
status_data = json.loads(sync_status.content[0].text)
print(
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
await asyncio.sleep(wait_interval)
await anyio.sleep(wait_interval)
waited += wait_interval
# Verify sync completed
@@ -135,7 +146,7 @@ Avoid blocking operations in async code.""",
assert call_result.isError is False, (
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
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
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(
"nc_semantic_search_answer",
arguments={
@@ -192,7 +205,7 @@ async def test_semantic_search_answer_no_results(nc_mcp_client):
assert call_result.isError is False, (
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
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
4. Verify at most 2 sources in response
"""
await require_vector_sync_tools(nc_mcp_client)
# Create multiple related notes
_note1 = await temporary_note_factory(
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
import asyncio
max_wait = 30
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(
"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:
break
await asyncio.sleep(wait_interval)
await anyio.sleep(wait_interval)
waited += wait_interval
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, (
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
assert len(result["sources"]) <= 2
@@ -282,6 +296,8 @@ async def test_semantic_search_answer_score_threshold(
3. Query with high threshold (0.9)
4. Verify only high-scoring results returned
"""
await require_vector_sync_tools(nc_mcp_client)
_note = await temporary_note_factory(
title="Exact Match Test",
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
import asyncio
max_wait = 30
wait_interval = 1
@@ -299,12 +314,12 @@ async def test_semantic_search_answer_score_threshold(
sync_status = await nc_mcp_client.call_tool(
"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:
break
await asyncio.sleep(wait_interval)
await anyio.sleep(wait_interval)
waited += wait_interval
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, (
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
# 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.
This test just verifies the parameter is correctly passed.
"""
await require_vector_sync_tools(nc_mcp_client)
_note = await temporary_note_factory(
title="Long Document",
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
import asyncio
max_wait = 30
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(
"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:
break
await asyncio.sleep(wait_interval)
await anyio.sleep(wait_interval)
waited += wait_interval
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, (
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
assert result is not None
+1 -2
View File
@@ -10,6 +10,7 @@ Uses SimpleEmbeddingProvider for deterministic, in-process embeddings
without requiring external services like Ollama.
"""
import math
import tempfile
from pathlib import Path
@@ -147,7 +148,6 @@ async def test_simple_embedding_provider_deterministic(simple_embedding_provider
assert len(embedding1) == 384
# Should be normalized (unit length)
import math
norm = math.sqrt(sum(x * x for x in embedding1))
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)
# Each should be normalized
import math
for emb in embeddings:
norm = math.sqrt(sum(x * x for x in emb))
+1 -2
View File
@@ -6,6 +6,7 @@ workflow completion rates, and cross-user operation latencies.
"""
import statistics
import time
from collections import Counter, defaultdict
from typing import Any
@@ -44,13 +45,11 @@ class OAuthBenchmarkMetrics:
def start(self):
"""Mark the start of the benchmark."""
import time
self.start_time = time.time()
def stop(self):
"""Mark the end of the benchmark."""
import time
self.end_time = time.time()
+4 -4
View File
@@ -5,8 +5,12 @@ Manages multiple OAuth-authenticated users for realistic multi-user load testing
"""
import logging
import secrets
import string
import time
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
import anyio
import httpx
@@ -333,8 +337,6 @@ class OAuthUserPool:
TimeoutError: If callback not received within timeout
ValueError: If token exchange fails
"""
import time
from urllib.parse import quote
logger.info(f"Starting Playwright OAuth flow for {username}...")
logger.debug(f"Using state: {state[:16]}...")
@@ -478,8 +480,6 @@ class UserSessionWrapper:
def generate_secure_password(length: int = 20) -> str:
"""Generate a secure random password."""
import secrets
import string
alphabet = string.ascii_letters + string.digits + "!@#$%^&*()"
return "".join(secrets.choice(alphabet) for _ in range(length))
+1 -4
View File
@@ -4,6 +4,7 @@ Workload definitions for load testing the MCP server.
Defines realistic operation mixes and individual operation functions.
"""
import json
import logging
import random
import time
@@ -91,8 +92,6 @@ class WorkloadOperations:
if result and len(result.content) > 0:
content = result.content[0]
if hasattr(content, "text"):
import json
note_data = json.loads(content.text)
note_id = note_data.get("id")
if note_id:
@@ -222,8 +221,6 @@ class MixedWorkload:
"nc_notes_get_note", {"note_id": note_id}
)
if get_result and len(get_result.content) > 0:
import json
note_data = json.loads(get_result.content[0].text)
etag = note_data.get("etag", "")
self._warmup_note_ids.append((note_id, etag))
+1 -1
View File
@@ -18,6 +18,7 @@ Usage:
import asyncio
import logging
import os
import re
import sys
# Add parent directory to path
@@ -127,7 +128,6 @@ async def main():
)
# Extract requesttoken from HTML
import re
token_match = re.search(r'data-requesttoken="([^"]+)"', settings_response.text)
if token_match:
+1 -1
View File
@@ -17,6 +17,7 @@ Architecture:
MCP Client Keycloak DCR Keycloak OAuth MCP Server Nextcloud APIs
"""
import json
import logging
import os
import secrets
@@ -623,7 +624,6 @@ async def test_keycloak_dcr_architecture():
}
logger.info("Keycloak DCR Architecture:")
import json
logger.info(json.dumps(architecture, indent=2))
+16 -7
View File
@@ -37,7 +37,7 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server
# Navigate to 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")
# 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 title: {await page.title()}")
# Check for key strings
# Check for key strings (Vue 3 UI)
checks = [
"Authorize Access",
"Authorization Required",
"MCP Server",
"Sign In Again",
"astrolabe",
"Enable Semantic Search", # oauth-required.php authorization button
"Service Status", # personal.php when authorized
"Background Sync Access", # personal.php when authorized
"What happens next?", # oauth-required.php steps
"Astrolabe", # Header
]
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]):
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
if "error" in page_content.lower():
logger.warning("Page contains 'error' keyword")
+65 -47
View File
@@ -105,21 +105,26 @@ async def authorized_nc_session(
# Step 2: Navigate 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")
page_content = await page.content()
# 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...")
# Click "Authorize Access" button
# Click "Enable Semantic Search" button (Vue 3 template text)
authorize_selectors = [
'button:has-text("Authorize")',
'a:has-text("Authorize")',
'[href*="oauth/authorize"]',
'button:has-text("Connect")',
'a:has-text("Enable Semantic Search")',
'button:has-text("Enable Semantic Search")',
'a:has-text("Sign In Again")',
"a.button.primary",
'[href*="oauth/login"]',
]
clicked = False
@@ -142,9 +147,17 @@ async def authorized_nc_session(
# Wait for page to load after clicking
await page.wait_for_load_state("networkidle", timeout=10000)
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
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...")
consent_selectors = [
@@ -163,7 +176,7 @@ async def authorized_nc_session(
continue
# 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")
logger.info("✓ OAuth authorization completed")
@@ -197,28 +210,32 @@ class TestNcPhpAppOAuth:
host = authorized_nc_session["host"]
# 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")
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 = [
"Connected",
"Disconnect",
"Server Connection",
"Session Information",
"MCP Server",
"Service Status",
"Background Sync Access",
"Manage Connection",
"Revoke Access",
"Service URL",
]
has_success_indicator = any(
indicator in page_content for indicator in success_indicators
)
found_indicators = [ind for ind in success_indicators if ind in page_content]
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:
screenshot_path = "/tmp/nc-php-app-auth-check.png"
await page.screenshot(path=screenshot_path)
logger.error(f"Authorization check failed. Screenshot: {screenshot_path}")
logger.error("Authorization check failed.")
assert has_success_indicator, "Settings page should show user is authorized"
logger.info("✓ Authorization verification passed")
@@ -232,7 +249,7 @@ class TestNcPhpAppOAuth:
page = authorized_nc_session["page"]
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")
page_content = await page.content()
@@ -243,12 +260,12 @@ class TestNcPhpAppOAuth:
logger.info(f"Screenshot saved: {screenshot_path}")
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 = [
"Server Connection",
"Session Information",
"Connection Management",
"MCP Server",
"Service Status",
"Service URL",
"Version",
"Background Sync Access",
]
found_indicators = [ind for ind in session_indicators if ind in page_content]
@@ -270,17 +287,17 @@ class TestNcPhpAppOAuth:
host = authorized_nc_session["host"]
# 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")
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 = [
"Server Connection", # Section header
"Server URL", # Server info
"Connection Management", # Connection section
"Vector Visualization", # Vector sync section
"Service Status", # Section header
"Service URL", # Server info from API
"Version", # Server version from management API
"Semantic Search", # Vector sync status
]
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"]
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")
page_content = await page.content()
# Admin page should show server status
# Admin page should show server status (Vue 3 AdminSettings.vue)
admin_indicators = [
"MCP Server",
"Server Status",
"Astrolabe",
"Service Status",
"Version",
"Semantic Search",
]
found_indicators = [ind for ind in admin_indicators if ind in page_content]
# Admin page should at least show the MCP Server header
assert "MCP Server" in page_content or "mcp" in page_content.lower(), (
"Admin settings page should show MCP Server section"
# Admin page should at least show the Astrolabe header or Service Status
assert "Astrolabe" in page_content or "Service Status" in page_content, (
"Admin settings page should show Astrolabe section"
)
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)
# 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")
page_content = await page.content()
# Check if user is authorized
if "Disconnect" not in page_content:
# Check if user is authorized (Vue 3 personal.php shows Disconnect/Revoke when authorized)
if "Disconnect" not in page_content and "Revoke Access" not in page_content:
pytest.skip("User not authorized - cannot test disconnect")
# Click disconnect button
@@ -384,10 +402,10 @@ class TestNcPhpAppDisconnect:
# Wait for page reload
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()
assert "Authorize" in page_content, (
"Settings page should show 'Authorize Access' after disconnect"
assert "Enable Semantic Search" in page_content, (
"Settings page should show 'Enable Semantic Search' after disconnect"
)
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.
"""
import logging
import httpx
import pytest
@pytest.mark.integration
async def test_prm_endpoint():
"""Test that the Protected Resource Metadata endpoint returns correct data."""
import httpx
# Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
async with httpx.AsyncClient() as client:
@@ -60,7 +62,6 @@ async def test_basicauth_shows_all_tools(nc_mcp_client):
@pytest.mark.integration
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."""
import logging
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
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."""
import logging
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
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."""
import logging
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
so users can provision Nextcloud access after authentication
"""
import logging
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.
Expected: Should see read tools but not write tools.
"""
import logging
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.
Expected: Should see write tools but not read-only tools.
"""
import logging
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.
Expected: Should see all 90+ tools (both read and write).
"""
import logging
logger = logging.getLogger(__name__)
+2 -3
View File
@@ -6,10 +6,12 @@ Tests the critical token exchange pattern that separates:
"""
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
@@ -21,9 +23,6 @@ pytestmark = pytest.mark.unit
@pytest.fixture
async def token_storage():
"""Create test token storage."""
import tempfile
from cryptography.fernet import Fernet
# Generate valid Fernet key
encryption_key = Fernet.generate_key()
+1 -8
View File
@@ -1,5 +1,6 @@
"""Integration tests for Calendar VTODO (task) MCP tools."""
import json
import logging
from datetime import datetime, timedelta
@@ -41,7 +42,6 @@ async def test_mcp_todo_complete_workflow(
# Extract UID from the result
result_data = create_result.content[0].text
import json
result_json = json.loads(result_data)
todo_uid = result_json["uid"]
@@ -156,7 +156,6 @@ async def test_mcp_list_todos_with_filters(
{"calendar_name": calendar_name, "status": "NEEDS-ACTION"},
)
assert result.isError is False
import json
data = json.loads(result.content[0].text)
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
import json
data = json.loads(search_result.content[0].text)
assert "todos" in data
@@ -388,8 +385,6 @@ async def test_mcp_todo_with_dates(
)
assert create_result.isError is False
import json
result_data = json.loads(create_result.content[0].text)
todo_uid = result_data["uid"]
@@ -432,8 +427,6 @@ async def test_mcp_todo_categories(
)
assert create_result.isError is False
import json
result_data = json.loads(create_result.content[0].text)
todo_uid = result_data["uid"]
+1 -4
View File
@@ -1,5 +1,6 @@
"""Tests for configuration validation."""
import logging
import os
from unittest.mock import patch
@@ -48,7 +49,6 @@ class TestQdrantConfigValidation:
def test_api_key_warning_in_local_mode(self, caplog):
"""Test that API key in local mode triggers warning."""
import logging
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
Settings(
@@ -59,7 +59,6 @@ class TestQdrantConfigValidation:
def test_api_key_no_warning_in_network_mode(self, caplog):
"""Test that API key in network mode doesn't trigger warning."""
import logging
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
Settings(
@@ -206,7 +205,6 @@ class TestChunkConfigValidation:
def test_small_chunk_size_warning(self, caplog):
"""Test that chunk size < 512 triggers warning."""
import logging
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
Settings(
@@ -221,7 +219,6 @@ class TestChunkConfigValidation:
def test_reasonable_chunk_size_no_warning(self, caplog):
"""Test that chunk size >= 512 doesn't trigger warning."""
import logging
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
Settings(
+308
View File
@@ -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)
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.5.0"
version = "0.7.2"
tag_format = "astrolabe-v$version"
version_scheme = "semver"
update_changelog_on_bump = true
+51
View File
@@ -25,6 +25,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Requires external MCP server deployment
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
## astrolabe-v0.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
+1 -1
View File
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
]]></description>
<version>0.5.0</version>
<version>0.7.2</version>
<licence>agpl</licence>
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
<namespace>Astrolabe</namespace>
+12 -2
View File
@@ -47,8 +47,8 @@ return [
],
[
'name' => 'credentials#deleteCredentials',
'url' => '/api/v1/background-sync/credentials',
'verb' => 'DELETE',
'url' => '/api/v1/background-sync/credentials/revoke',
'verb' => 'POST',
],
[
'name' => 'credentials#getStatus',
@@ -74,6 +74,16 @@ return [
],
// 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',
'url' => '/api/admin/search-settings',
+64
View File
@@ -254,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.
*
+13 -2
View File
@@ -335,9 +335,10 @@ class OAuthController extends Controller {
]);
} else {
// Fall back to Nextcloud's OIDC app
// Use internal localhost URL for HTTP request (always accessible from inside container)
// The OIDC discovery response will contain proper external URLs based on overwrite.cli.url
// Use internal localhost URL for HTTP request (accessible from inside container)
// We'll transform the returned URLs to external format after discovery
$discoveryUrl = 'http://localhost/.well-known/openid-configuration';
$internalBaseUrl = 'http://localhost';
$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
'discovery_url' => $discoveryUrl,
@@ -368,6 +369,16 @@ class OAuthController extends Controller {
}
$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', [
'auth_endpoint' => $authEndpoint,
'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',
+7 -34
View File
@@ -47,11 +47,7 @@ class Admin implements ISettings {
* @return TemplateResponse
*/
public function getForm(): TemplateResponse {
// Fetch data from MCP server
$serverStatus = $this->client->getStatus();
$vectorSyncStatus = $this->client->getVectorSyncStatus();
// Get configuration from config.php
// Get configuration from config.php (local, fast)
$serverUrl = $this->config->getSystemValue('mcp_server_url', '');
$apiKeyConfigured = !empty($this->config->getSystemValue('mcp_server_api_key', ''));
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
@@ -59,21 +55,6 @@ class Admin implements ISettings {
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
$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
$searchSettings = [
'algorithm' => $this->config->getAppValue(
@@ -98,27 +79,19 @@ class Admin implements ISettings {
),
];
// Provide initial state for Vue.js frontend (if needed)
$this->initialState->provideInitialState('server-data', [
'serverStatus' => $serverStatus,
'vectorSyncStatus' => $vectorSyncStatus,
// Provide initial state for Vue.js frontend
// MCP server data will be fetched asynchronously by Vue component
$this->initialState->provideInitialState('admin-config', [
'config' => [
'serverUrl' => $serverUrl,
'apiKeyConfigured' => $apiKeyConfigured,
'clientIdConfigured' => $clientIdConfigured,
'clientSecretConfigured' => $clientSecretConfigured,
],
'searchSettings' => $searchSettings,
]);
$parameters = [
'serverStatus' => $serverStatus,
'vectorSyncStatus' => $vectorSyncStatus,
'serverUrl' => $serverUrl,
'apiKeyConfigured' => $apiKeyConfigured,
'clientIdConfigured' => $clientIdConfigured,
'clientSecretConfigured' => $clientSecretConfigured,
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'searchSettings' => $searchSettings,
];
$parameters = [];
return new TemplateResponse(
Application::APP_ID,
+1142 -4086
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "astrolabe",
"version": "0.5.0",
"version": "0.7.2",
"license": "AGPL-3.0-or-later",
"engines": {
"node": "^22.0.0",
@@ -19,20 +19,23 @@
],
"dependencies": {
"@nextcloud/axios": "^2.5.1",
"@nextcloud/dialogs": "^7.2.0",
"@nextcloud/initial-state": "^3.0.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^8.29.2",
"@nextcloud/vue": "^9.0.0",
"markdown-it": "^14.1.0",
"plotly.js-dist-min": "^2.35.3",
"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"
},
"devDependencies": {
"@nextcloud/browserslist-config": "3.1.2",
"@nextcloud/eslint-config": "8.4.2",
"@nextcloud/stylelint-config": "3.1.1",
"@nextcloud/vite-config": "1.7.2",
"@vitejs/plugin-vue": "^6.0.3",
"sass-embedded": "^1.97.1",
"terser": "5.44.1",
"vite": "7.2.7"
}
+20 -18
View File
@@ -48,10 +48,11 @@
<div class="mcp-search-card">
<div class="mcp-search-row">
<NcTextField
:value.sync="query"
:value="query"
:label="t('astrolabe', 'Search query')"
:placeholder="t('astrolabe', 'Enter your search query...')"
class="mcp-search-input"
@update:value="query = $event"
@keyup.enter="performSearch" />
<NcSelect
@@ -104,10 +105,11 @@
<div class="mcp-option-group">
<label>{{ t('astrolabe', 'Result Limit') }}</label>
<NcTextField
:value.sync="limit"
:value="limit"
type="number"
:min="1"
:max="100" />
:max="100"
@update:value="limit = Number($event)" />
</div>
<div class="mcp-option-group">
@@ -152,9 +154,9 @@
<div class="mcp-viz-header">
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
<NcCheckboxRadioSwitch
:checked.sync="showQueryPoint"
:checked="showQueryPoint"
type="switch"
@update:checked="updatePlot">
@update:checked="showQueryPoint = $event; updatePlot()">
{{ t('astrolabe', 'Show query point') }}
</NcCheckboxRadioSwitch>
</div>
@@ -364,17 +366,17 @@
</template>
<script>
import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcContent from '@nextcloud/vue/components/NcContent'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import Magnify from 'vue-material-design-icons/Magnify.vue'
import ChartBox from 'vue-material-design-icons/ChartBox.vue'
@@ -505,7 +507,7 @@ export default {
// Check for URL parameters to open chunk viewer
this.handleUrlParameters()
},
beforeDestroy() {
beforeUnmount() {
// Clean up Plotly event handlers to prevent memory leaks
const plotDiv = document.getElementById('viz-plot')
if (plotDiv && plotDiv.on) {
@@ -648,7 +650,7 @@ export default {
},
toggleExcerpt(index) {
this.$set(this.expandedExcerpts, index, !this.expandedExcerpts[index])
this.expandedExcerpts[index] = !this.expandedExcerpts[index]
},
truncateExcerpt(text, maxLength = 150) {
+11 -238
View File
@@ -1,245 +1,18 @@
/**
* Admin settings page JavaScript for Astrolabe.
* Admin settings page Vue app for Astrolabe.
*
* Handles:
* - Loading webhook presets
* - Enabling/disabling webhook presets
* - Search settings form submission
* Mounts the AdminSettings Vue component for async loading
* and improved UX.
*/
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import './styles/settings.css'
import { createApp } from 'vue'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import AdminSettings from './components/admin/AdminSettings.vue'
document.addEventListener('DOMContentLoaded', () => {
// Initialize search settings form
initSearchSettingsForm()
const app = createApp(AdminSettings)
// Initialize webhook management (only if webhook section exists)
if (document.getElementById('webhook-presets')) {
initWebhookManagement()
}
})
// Add translation methods globally
app.config.globalProperties.t = t
app.config.globalProperties.n = n
/**
* Initialize search settings form handling.
*/
function initSearchSettingsForm() {
const form = document.getElementById('astrolabe-search-settings-form')
if (!form) return
const scoreThresholdInput = document.getElementById('search-score-threshold')
const scoreThresholdValue = document.getElementById('score-threshold-value')
// Update score threshold display when slider changes
if (scoreThresholdInput && scoreThresholdValue) {
scoreThresholdInput.addEventListener('input', (e) => {
scoreThresholdValue.textContent = e.target.value + '%'
})
}
// Handle form submission
form.addEventListener('submit', async (e) => {
e.preventDefault()
const formData = new FormData(form)
const data = {
algorithm: formData.get('algorithm'),
fusion: formData.get('fusion'),
scoreThreshold: parseInt(formData.get('scoreThreshold')),
limit: parseInt(formData.get('limit')),
}
const statusEl = document.getElementById('search-settings-status')
if (statusEl) {
statusEl.textContent = 'Saving...'
statusEl.className = 'mcp-status-message'
}
try {
const response = await axios.post(
generateUrl('/apps/astrolabe/api/admin/search-settings'),
data,
{ headers: { 'Content-Type': 'application/json' } },
)
if (response.data.success) {
if (statusEl) {
statusEl.textContent = '✓ Settings saved'
statusEl.className = 'mcp-status-message success'
setTimeout(() => {
statusEl.textContent = ''
}, 3000)
}
}
} catch (error) {
console.error('Failed to save search settings:', error)
if (statusEl) {
statusEl.textContent = '✗ Failed to save'
statusEl.className = 'mcp-status-message error'
}
}
})
}
/**
* Initialize webhook management UI.
*/
async function initWebhookManagement() {
const container = document.getElementById('webhook-presets-container')
if (!container) return
try {
// Load webhook presets from API
const response = await axios.get(
generateUrl('/apps/astrolabe/api/admin/webhooks/presets'),
)
if (!response.data.success) {
throw new Error(response.data.error || 'Failed to load presets')
}
const presets = response.data.presets
renderWebhookPresets(container, presets)
} catch (error) {
console.error('Failed to load webhook presets:', error)
container.innerHTML = `
<div class="notecard notecard-error">
<p><strong>Error loading webhook presets:</strong></p>
<p>${error.message || 'Unknown error'}</p>
</div>
`
}
}
/**
* Render webhook preset cards.
*
* @param {HTMLElement} container Container element
* @param {object} presets Preset configurations
*/
function renderWebhookPresets(container, presets) {
const presetIds = Object.keys(presets)
if (presetIds.length === 0) {
container.innerHTML = `
<div class="notecard notecard-info">
<p>No webhook presets available. Install supported apps (Notes, Calendar, Tables, Forms) to enable webhooks.</p>
</div>
`
return
}
// Create preset cards grid
const grid = document.createElement('div')
grid.className = 'mcp-preset-grid'
presetIds.forEach(presetId => {
const preset = presets[presetId]
const card = createPresetCard(presetId, preset)
grid.appendChild(card)
})
container.innerHTML = ''
container.appendChild(grid)
}
/**
* Create a webhook preset card.
*
* @param {string} presetId Preset ID
* @param {object} preset Preset configuration
* @return {HTMLElement} Card element
*/
function createPresetCard(presetId, preset) {
const card = document.createElement('div')
card.className = 'mcp-preset-card'
card.dataset.presetId = presetId
const statusClass = preset.enabled ? 'enabled' : 'disabled'
const statusText = preset.enabled ? 'Enabled' : 'Disabled'
const buttonText = preset.enabled ? 'Disable' : 'Enable'
const buttonClass = preset.enabled ? 'secondary' : 'primary'
card.innerHTML = `
<div class="mcp-preset-header">
<h4>${escapeHtml(preset.name)}</h4>
<span class="mcp-preset-status mcp-status-${statusClass}">${statusText}</span>
</div>
<p class="mcp-preset-description">${escapeHtml(preset.description)}</p>
<div class="mcp-preset-meta">
<span class="mcp-preset-app">App: ${escapeHtml(preset.app)}</span>
<span class="mcp-preset-events">${preset.events.length} events</span>
</div>
<div class="mcp-preset-actions">
<button class="mcp-preset-toggle ${buttonClass}" data-preset-id="${presetId}">
${buttonText}
</button>
</div>
`
// Attach event listener to toggle button
const toggleBtn = card.querySelector('.mcp-preset-toggle')
toggleBtn.addEventListener('click', () => togglePreset(presetId, preset.enabled))
return card
}
/**
* Toggle a webhook preset (enable/disable).
*
* @param {string} presetId Preset ID
* @param {boolean} currentlyEnabled Current enabled state
*/
async function togglePreset(presetId, currentlyEnabled) {
const card = document.querySelector(`[data-preset-id="${presetId}"]`)
if (!card) return
const toggleBtn = card.querySelector('.mcp-preset-toggle')
const originalText = toggleBtn.textContent
// Disable button during request
toggleBtn.disabled = true
toggleBtn.textContent = currentlyEnabled ? 'Disabling...' : 'Enabling...'
try {
const action = currentlyEnabled ? 'disable' : 'enable'
const url = generateUrl(`/apps/astrolabe/api/admin/webhooks/presets/${presetId}/${action}`)
const response = await axios.post(url)
if (!response.data.success) {
throw new Error(response.data.error || `Failed to ${action} preset`)
}
// Reload presets to update UI
await initWebhookManagement()
// Show success notification
OC.Notification.showTemporary(response.data.message || `Preset ${action}d successfully`)
} catch (error) {
console.error(`Failed to toggle preset ${presetId}:`, error)
// Restore button state
toggleBtn.disabled = false
toggleBtn.textContent = originalText
// Show error notification
OC.Notification.showTemporary(
error.message || 'Failed to toggle webhook preset',
{ type: 'error' },
)
}
}
/**
* Escape HTML to prevent XSS.
*
* @param {string} text Text to escape
* @return {string} Escaped text
*/
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
app.mount('#astrolabe-admin-settings')
+38 -51
View File
@@ -3,65 +3,52 @@
<div class="markdown-viewer" v-html="html" />
</template>
<script>
<script setup>
import { ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
export default {
name: 'MarkdownViewer',
props: {
content: {
type: String,
required: true,
},
const props = defineProps({
content: {
type: String,
required: true,
},
})
data() {
const md = new MarkdownIt({
html: false, // Disable HTML for security
linkify: true,
breaks: true,
typographer: true,
})
const html = ref('')
return {
html: '',
md,
}
},
// Initialize markdown renderer
const md = new MarkdownIt({
html: false, // Disable HTML for security
linkify: true,
breaks: true,
typographer: true,
})
watch: {
content: {
immediate: true,
handler(newContent) {
this.renderMarkdown(newContent)
},
},
},
function renderMarkdown(text) {
if (!text) {
html.value = ''
return
}
methods: {
renderMarkdown(text) {
if (!text) {
this.html = ''
return
}
try {
this.html = this.md.render(text)
} catch (error) {
console.error('Markdown rendering error:', error)
// Fallback to escaped plain text
this.html = `<pre>${this.escapeHtml(text)}</pre>`
}
},
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
},
},
try {
html.value = md.render(text)
} catch (error) {
console.error('Markdown rendering error:', error)
// Fallback to escaped plain text
html.value = `<pre>${escapeHtml(text)}</pre>`
}
}
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// Watch for content changes
watch(() => props.content, (newContent) => {
renderMarkdown(newContent)
}, { immediate: true })
</script>
<style scoped lang="scss">
+146 -145
View File
@@ -8,165 +8,166 @@
<AlertCircle :size="48" />
<p>{{ error }}</p>
</div>
<div v-else ref="container" class="pdf-canvas-container">
<canvas ref="canvas" />
<div v-else ref="containerRef" class="pdf-canvas-container">
<canvas ref="canvasRef" />
</div>
</div>
</template>
<script>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import { NcLoadingIcon } from '@nextcloud/vue'
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
export default {
name: 'PDFViewer',
components: {
NcLoadingIcon,
AlertCircle,
const props = defineProps({
filePath: {
type: String,
required: true,
},
props: {
filePath: {
type: String,
required: true,
},
pageNumber: {
type: Number,
default: 1,
},
scale: {
type: Number,
default: 1.5,
},
pageNumber: {
type: Number,
default: 1,
},
data() {
return {
pdfDoc: null,
loading: true,
error: null,
totalPages: 0,
scale: {
type: Number,
default: 1.5,
},
})
const emit = defineEmits(['loaded', 'error', 'page-rendered'])
// Reactive state
const pdfDoc = ref(null)
const loading = ref(true)
const error = ref(null)
const totalPages = ref(0)
const canvasRef = ref(null)
const containerRef = ref(null)
// Methods
async function loadPDF() {
loading.value = true
error.value = null
try {
// Clean and encode the file path
const cleanPath = props.filePath.startsWith('/')
? props.filePath.substring(1)
: props.filePath
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
// Load PDF document
const loadingTask = pdfjsLib.getDocument({
url: downloadUrl,
withCredentials: true,
useWorkerFetch: false, // Disable worker fetch for CSP compliance
isEvalSupported: false, // Disable eval for CSP
})
pdfDoc.value = await loadingTask.promise
totalPages.value = pdfDoc.value.numPages
emit('loaded', { totalPages: totalPages.value })
// Set loading to false - the watcher will handle rendering
loading.value = false
} catch (err) {
console.error('PDF load error:', err)
// Provide user-friendly error messages
if (err.name === 'MissingPDFException') {
error.value = t('astrolabe', 'PDF file not found')
} else if (err.name === 'InvalidPDFException') {
error.value = t('astrolabe', 'Invalid or corrupted PDF file')
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
error.value = t('astrolabe', 'Network error loading PDF')
} else if (err.message?.includes('404')) {
error.value = t('astrolabe', 'PDF file not found')
} else {
error.value = t('astrolabe', 'Unable to load PDF file')
}
},
watch: {
pageNumber(newPage) {
if (this.pdfDoc && newPage > 0 && newPage <= this.totalPages) {
this.renderPage(newPage)
}
},
filePath() {
// Reload PDF if file path changes
this.loadPDF()
},
async loading(newLoading) {
// When loading completes, wait for canvas to be available and render
if (!newLoading && this.pdfDoc && !this.error) {
// Wait for Vue to update DOM
await this.$nextTick()
// Canvas should now be rendered (v-else condition)
if (this.$refs.canvas) {
await this.renderPage(this.pageNumber)
}
}
},
},
async mounted() {
await this.loadPDF()
},
beforeUnmount() {
if (this.pdfDoc) {
this.pdfDoc.destroy()
}
},
methods: {
t,
async loadPDF() {
this.loading = true
this.error = null
try {
// Clean and encode the file path
const cleanPath = this.filePath.startsWith('/')
? this.filePath.substring(1)
: this.filePath
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
// Load PDF document
const loadingTask = pdfjsLib.getDocument({
url: downloadUrl,
withCredentials: true,
useWorkerFetch: false, // Disable worker fetch for CSP compliance
isEvalSupported: false, // Disable eval for CSP
})
this.pdfDoc = await loadingTask.promise
this.totalPages = this.pdfDoc.numPages
this.$emit('loaded', { totalPages: this.totalPages })
// Set loading to false - the watcher will handle rendering
this.loading = false
} catch (err) {
console.error('PDF load error:', err)
// Provide user-friendly error messages
if (err.name === 'MissingPDFException') {
this.error = t('astrolabe', 'PDF file not found')
} else if (err.name === 'InvalidPDFException') {
this.error = t('astrolabe', 'Invalid or corrupted PDF file')
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
this.error = t('astrolabe', 'Network error loading PDF')
} else if (err.message?.includes('404')) {
this.error = t('astrolabe', 'PDF file not found')
} else {
this.error = t('astrolabe', 'Unable to load PDF file')
}
this.$emit('error', err)
this.loading = false
}
},
async renderPage(pageNum) {
if (!this.pdfDoc) {
return
}
try {
const page = await this.pdfDoc.getPage(pageNum)
const canvas = this.$refs.canvas
if (!canvas) {
console.error('PDF canvas ref not found')
this.error = t('astrolabe', 'Canvas element not available')
return
}
const context = canvas.getContext('2d')
// Use scale for better resolution on high-DPI screens
const viewport = page.getViewport({ scale: this.scale })
canvas.height = viewport.height
canvas.width = viewport.width
// Render page to canvas
const renderContext = {
canvasContext: context,
viewport,
}
await page.render(renderContext).promise
this.$emit('page-rendered', { pageNumber: pageNum })
} catch (err) {
console.error('PDF render error:', err)
this.error = t('astrolabe', 'Error rendering PDF page')
this.$emit('error', err)
}
},
},
emit('error', err)
loading.value = false
}
}
async function renderPage(pageNum) {
if (!pdfDoc.value) {
return
}
try {
const page = await pdfDoc.value.getPage(pageNum)
const canvas = canvasRef.value
if (!canvas) {
console.error('PDF canvas ref not found')
error.value = t('astrolabe', 'Canvas element not available')
return
}
const context = canvas.getContext('2d')
// Use scale for better resolution on high-DPI screens
const viewport = page.getViewport({ scale: props.scale })
canvas.height = viewport.height
canvas.width = viewport.width
// Render page to canvas
const renderContext = {
canvasContext: context,
viewport,
}
await page.render(renderContext).promise
emit('page-rendered', { pageNumber: pageNum })
} catch (err) {
console.error('PDF render error:', err)
error.value = t('astrolabe', 'Error rendering PDF page')
emit('error', err)
}
}
// Watchers
watch(() => props.pageNumber, (newPage) => {
if (pdfDoc.value && newPage > 0 && newPage <= totalPages.value) {
renderPage(newPage)
}
})
watch(() => props.filePath, () => {
// Reload PDF if file path changes
loadPDF()
})
watch(loading, async (newLoading) => {
// When loading completes, wait for canvas to be available and render
if (!newLoading && pdfDoc.value && !error.value) {
// Wait for Vue to update DOM
await nextTick()
// Canvas should now be rendered (v-else condition)
if (canvasRef.value) {
await renderPage(props.pageNumber)
}
}
})
// Lifecycle hooks
onMounted(() => {
loadPDF()
})
onBeforeUnmount(() => {
if (pdfDoc.value) {
pdfDoc.value.destroy()
}
})
</script>
<style scoped lang="scss">
@@ -0,0 +1,685 @@
<template>
<div class="admin-settings">
<NcLoadingIcon v-if="loading" :size="64" class="loading-icon" />
<NcNoteCard v-else-if="error" type="error">
<p><strong>{{ t('astrolabe', 'Cannot connect to MCP server') }}</strong></p>
<p>{{ error }}</p>
<p class="help-text">{{ t('astrolabe', 'Ensure MCP server is running and accessible. Check config.php for correct mcp_server_url.') }}</p>
<NcButton type="primary" @click="retryConnection">
<template #icon>
<Refresh :size="20" />
</template>
{{ t('astrolabe', 'Retry Connection') }}
</NcButton>
</NcNoteCard>
<template v-else>
<!-- Service Status -->
<div class="admin-section">
<h3>{{ t('astrolabe', 'Service Status') }}</h3>
<div class="status-card">
<p><strong>{{ t('astrolabe', 'Version') }}:</strong> {{ serverStatus?.version || 'Unknown' }}</p>
<p v-if="serverStatus?.uptime_seconds">
<strong>{{ t('astrolabe', 'Uptime') }}:</strong> {{ formatUptime(serverStatus.uptime_seconds) }}
</p>
<p>
<strong>{{ t('astrolabe', 'Semantic Search') }}:</strong>
<span v-if="vectorSyncEnabled" class="status-badge status-enabled">
{{ t('astrolabe', 'Enabled') }}
</span>
<span v-else class="status-badge status-disabled">
{{ t('astrolabe', 'Disabled') }}
</span>
</p>
</div>
</div>
<!-- Indexing Metrics -->
<div v-if="vectorSyncEnabled && vectorSyncStatus" class="admin-section">
<h3>{{ t('astrolabe', 'Indexing Metrics') }}</h3>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">{{ t('astrolabe', 'Status') }}</div>
<div class="metric-value" :class="`status-${vectorSyncStatus.status}`">
{{ vectorSyncStatus.status }}
</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('astrolabe', 'Indexed Documents') }}</div>
<div class="metric-value">{{ formatNumber(vectorSyncStatus.indexed_documents) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('astrolabe', 'Pending Documents') }}</div>
<div class="metric-value">{{ formatNumber(vectorSyncStatus.pending_documents) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('astrolabe', 'Processing Rate') }}</div>
<div class="metric-value">{{ formatNumber(vectorSyncStatus.documents_per_second, 1) }} docs/sec</div>
</div>
</div>
<NcButton type="secondary" @click="refreshStatus">
<template #icon>
<Refresh :size="20" />
</template>
{{ t('astrolabe', 'Refresh Status') }}
</NcButton>
</div>
<!-- Webhook Management -->
<div v-if="vectorSyncEnabled" class="admin-section">
<h3>{{ t('astrolabe', 'Webhook Management') }}</h3>
<p class="section-description">
{{ t('astrolabe', 'Configure real-time synchronization for Nextcloud apps using webhooks. Webhooks provide instant updates to the MCP server when content changes.') }}
</p>
<div v-if="webhooksLoading" class="loading-indicator">
<NcLoadingIcon :size="32" />
<p>{{ t('astrolabe', 'Loading webhook presets...') }}</p>
</div>
<NcNoteCard v-else-if="webhooksError" type="warning">
<p><strong>{{ t('astrolabe', 'Authorization Required') }}</strong></p>
<p v-if="webhooksError.includes('authorization')">
{{ t('astrolabe', 'To manage webhooks, you must first authorize Astrolabe with the MCP server in your Personal Settings.') }}
</p>
<p v-else>{{ webhooksError }}</p>
<div class="webhook-auth-actions">
<NcButton type="primary" @click="openPersonalSettings">
{{ t('astrolabe', 'Go to Personal Settings') }}
</NcButton>
</div>
</NcNoteCard>
<template v-else>
<div v-if="webhookPresets.length === 0" class="empty-state">
<NcNoteCard type="info">
<p>{{ t('astrolabe', 'No webhook presets available. Install supported apps (Notes, Calendar, Tables, Forms) to enable webhooks.') }}</p>
</NcNoteCard>
</div>
<div v-else class="webhook-presets-grid">
<div v-for="preset in webhookPresets" :key="preset.id" class="webhook-preset-card">
<div class="preset-header">
<h4>{{ preset.name }}</h4>
<span :class="`preset-status preset-status-${preset.enabled ? 'enabled' : 'disabled'}`">
{{ preset.enabled ? t('astrolabe', 'Enabled') : t('astrolabe', 'Disabled') }}
</span>
</div>
<p class="preset-description">{{ preset.description }}</p>
<div class="preset-meta">
<span class="preset-app">{{ t('astrolabe', 'App') }}: {{ preset.app }}</span>
<span class="preset-events">{{ preset.events.length }} {{ t('astrolabe', 'events') }}</span>
</div>
<div class="preset-actions">
<NcButton
:type="preset.enabled ? 'secondary' : 'primary'"
:disabled="preset.toggling"
@click="toggleWebhookPreset(preset)">
{{ preset.toggling ? t('astrolabe', 'Please wait...') : (preset.enabled ? t('astrolabe', 'Disable') : t('astrolabe', 'Enable')) }}
</NcButton>
</div>
</div>
</div>
<NcNoteCard type="info" class="webhook-info">
<p><strong>{{ t('astrolabe', 'How Webhooks Work') }}</strong></p>
<ul>
<li>{{ t('astrolabe', 'Enable a preset to register webhooks for that app with the MCP server') }}</li>
<li>{{ t('astrolabe', 'When content changes in Nextcloud, webhooks notify the MCP server instantly') }}</li>
<li>{{ t('astrolabe', 'The MCP server updates its vector index in real-time for semantic search') }}</li>
<li>{{ t('astrolabe', 'Disable a preset to stop receiving updates for that app') }}</li>
</ul>
</NcNoteCard>
<NcNoteCard type="warning" class="webhook-requirements">
<p><strong>{{ t('astrolabe', 'Requirements') }}</strong></p>
<ul>
<li>{{ t('astrolabe', 'The webhook_listeners app must be installed and enabled in Nextcloud') }}</li>
<li>{{ t('astrolabe', 'The MCP server must be reachable from your Nextcloud instance') }}</li>
<li>{{ t('astrolabe', 'You must have authorized Astrolabe with the MCP server (see Personal Settings)') }}</li>
</ul>
</NcNoteCard>
</template>
</div>
<!-- Search Settings -->
<div v-if="vectorSyncEnabled" class="admin-section">
<h3>{{ t('astrolabe', 'AI Search Provider Settings') }}</h3>
<p class="section-description">
{{ t('astrolabe', 'Configure the default search parameters for the AI Search provider in Nextcloud unified search.') }}
</p>
<div class="settings-form">
<NcSelect
v-model="settings.algorithm"
:options="algorithmOptions"
:label="t('astrolabe', 'Search Algorithm')"
class="form-field" />
<p class="help-text">
{{ t('astrolabe', 'Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.') }}
</p>
<NcSelect
v-model="settings.fusion"
:options="fusionOptions"
:label="t('astrolabe', 'Fusion Method')"
class="form-field" />
<p class="help-text">
{{ t('astrolabe', 'Only applies to hybrid search. RRF balances results well for most queries. DBSF may work better when keyword matches are over/under-weighted.') }}
</p>
<div class="form-field">
<label>{{ t('astrolabe', 'Minimum Score Threshold') }}: {{ settings.scoreThreshold }}%</label>
<input
v-model="settings.scoreThreshold"
type="range"
min="0"
max="100"
step="5"
class="score-slider" />
<p class="help-text">
{{ t('astrolabe', 'Filter out results below this relevance score. Set to 0 to show all results.') }}
</p>
</div>
<NcTextField
:value="settings.limit"
:label="t('astrolabe', 'Maximum Results')"
type="number"
:min="5"
:max="100"
:step="5"
class="form-field"
@update:value="settings.limit = Number($event)" />
<p class="help-text">
{{ t('astrolabe', 'Maximum number of results to return per search query (5-100).') }}
</p>
<div class="form-actions">
<NcButton type="primary" :disabled="saving" @click="saveSettings">
{{ saving ? t('astrolabe', 'Saving...') : t('astrolabe', 'Save Settings') }}
</NcButton>
</div>
</div>
</div>
<!-- Documentation -->
<div class="admin-section">
<h3>{{ t('astrolabe', 'Documentation') }}</h3>
<ul class="doc-links">
<li>
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">
{{ t('astrolabe', 'Configuration Guide') }}
</a>
</li>
<li>
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server" target="_blank">
{{ t('astrolabe', 'GitHub Repository') }}
</a>
</li>
</ul>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import { showError, showSuccess } from '@nextcloud/dialogs'
import {
NcLoadingIcon,
NcNoteCard,
NcButton,
NcSelect,
NcTextField,
} from '@nextcloud/vue'
import Refresh from 'vue-material-design-icons/Refresh.vue'
// Reactive state
const loading = ref(true)
const error = ref(null)
const serverStatus = ref(null)
const vectorSyncStatus = ref(null)
const vectorSyncEnabled = ref(false)
const saving = ref(false)
// Webhook management state
const webhooksLoading = ref(false)
const webhooksError = ref(null)
const webhookPresets = ref([])
// Load initial state from PHP
const initialData = loadState('astrolabe', 'admin-config', {})
const settings = ref(initialData.searchSettings || {
algorithm: 'hybrid',
fusion: 'rrf',
scoreThreshold: 0,
limit: 20,
})
// Computed properties
const algorithmOptions = computed(() => [
{ id: 'hybrid', label: t('astrolabe', 'Hybrid (Recommended)') },
{ id: 'semantic', label: t('astrolabe', 'Semantic Only') },
{ id: 'bm25', label: t('astrolabe', 'Keyword (BM25) Only') },
])
const fusionOptions = computed(() => [
{ id: 'rrf', label: t('astrolabe', 'RRF - Reciprocal Rank Fusion (Recommended)') },
{ id: 'dbsf', label: t('astrolabe', 'DBSF - Distribution-Based Score Fusion') },
])
// Methods
async function loadServerStatus() {
loading.value = true
error.value = null
try {
// Fetch server status asynchronously
const [statusResponse, syncResponse] = await Promise.all([
axios.get(generateUrl('/apps/astrolabe/api/admin/server-status')),
axios.get(generateUrl('/apps/astrolabe/api/admin/vector-status')),
])
if (statusResponse.data.success) {
serverStatus.value = statusResponse.data.status
vectorSyncEnabled.value = statusResponse.data.status?.vector_sync_enabled ?? false
}
if (syncResponse.data.success) {
vectorSyncStatus.value = syncResponse.data.status
}
} catch (err) {
console.error('Failed to load server status:', err)
error.value = err.response?.data?.error || err.message || t('astrolabe', 'Network error')
} finally {
loading.value = false
}
}
async function refreshStatus() {
await loadServerStatus()
showSuccess(t('astrolabe', 'Status refreshed'))
}
async function retryConnection() {
// Clear error and retry loading server status
error.value = null
loading.value = true
await loadServerStatus()
}
async function saveSettings() {
saving.value = true
try {
const response = await axios.post(
generateUrl('/apps/astrolabe/api/admin/search-settings'),
settings.value,
{ headers: { 'Content-Type': 'application/json' } },
)
if (response.data.success) {
showSuccess(t('astrolabe', 'Settings saved successfully'))
}
} catch (err) {
console.error('Failed to save settings:', err)
showError(t('astrolabe', 'Failed to save settings'))
} finally {
saving.value = false
}
}
async function loadWebhookPresets() {
webhooksLoading.value = true
webhooksError.value = null
try {
const response = await axios.get(generateUrl('/apps/astrolabe/api/admin/webhooks/presets'))
if (response.data.success) {
// Convert presets object to array with IDs
const presetsObj = response.data.presets
webhookPresets.value = Object.keys(presetsObj).map(id => ({
id,
...presetsObj[id],
toggling: false,
}))
} else {
webhooksError.value = response.data.error || t('astrolabe', 'Failed to load webhook presets')
}
} catch (err) {
console.error('Failed to load webhook presets:', err)
webhooksError.value = err.response?.data?.error || err.message || t('astrolabe', 'Network error')
} finally {
webhooksLoading.value = false
}
}
async function toggleWebhookPreset(preset) {
preset.toggling = true
const endpoint = preset.enabled
? `/apps/astrolabe/api/admin/webhooks/presets/${preset.id}/disable`
: `/apps/astrolabe/api/admin/webhooks/presets/${preset.id}/enable`
try {
const response = await axios.post(generateUrl(endpoint))
if (response.data.success) {
// Toggle the enabled state
preset.enabled = !preset.enabled
showSuccess(response.data.message || (preset.enabled ? t('astrolabe', 'Webhook preset enabled') : t('astrolabe', 'Webhook preset disabled')))
} else {
showError(response.data.error || t('astrolabe', 'Failed to toggle webhook preset'))
}
} catch (err) {
console.error('Failed to toggle webhook preset:', err)
showError(err.response?.data?.error || err.message || t('astrolabe', 'Network error'))
} finally {
preset.toggling = false
}
}
function openPersonalSettings() {
window.location.href = generateUrl('/settings/user/astrolabe')
}
function formatUptime(seconds) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return t('astrolabe', '{hours} hours, {minutes} minutes', { hours, minutes })
}
function formatNumber(value, decimals = 0) {
if (value === undefined || value === null) return '0'
return Number(value).toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
}
// Lifecycle hooks
onMounted(async () => {
await loadServerStatus()
// Load webhook presets if vector sync is enabled
if (vectorSyncEnabled.value) {
await loadWebhookPresets()
}
})
</script>
<style scoped lang="scss">
.admin-settings {
padding: 20px;
max-width: 900px;
// Fix NcNoteCard icon sizing issues in Vue 3/@nextcloud/vue 9
:deep(.notecard) {
max-width: 100%;
margin-bottom: 16px;
.notecard__icon {
flex-shrink: 0;
width: 24px;
height: 24px;
svg {
width: 24px;
height: 24px;
}
}
}
}
.loading-icon {
margin: 40px auto;
display: block;
}
.admin-section {
margin-bottom: 32px;
h3 {
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 600;
}
}
.section-description {
color: var(--color-text-maxcontrast);
margin-bottom: 16px;
}
.help-text {
color: var(--color-text-maxcontrast);
font-size: 13px;
margin-top: 8px;
}
.status-card {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 20px;
p {
margin: 8px 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
.status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
&.status-enabled {
background: var(--color-success);
color: white;
}
&.status-disabled {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.metric-card {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 20px;
text-align: center;
}
.metric-label {
font-size: 13px;
color: var(--color-text-maxcontrast);
margin-bottom: 8px;
}
.metric-value {
font-size: 24px;
font-weight: 600;
color: var(--color-main-text);
&.status-idle {
color: var(--color-success);
}
&.status-syncing {
color: var(--color-warning);
}
&.status-error {
color: var(--color-error);
}
}
.settings-form {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 20px;
}
.form-field {
margin-bottom: 20px;
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: var(--color-main-text);
}
}
.score-slider {
width: 100%;
accent-color: var(--color-primary-element);
}
.form-actions {
display: flex;
align-items: center;
gap: 16px;
margin-top: 24px;
}
.doc-links {
list-style: none;
padding: 0;
li {
margin-bottom: 8px;
}
a {
color: var(--color-primary-element);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
// Webhook management styles
.loading-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px;
color: var(--color-text-maxcontrast);
}
.webhook-presets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.webhook-preset-card {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 16px;
transition: border-color 0.2s ease;
&:hover {
border-color: var(--color-primary-element);
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
}
.preset-status {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
&.preset-status-enabled {
background: var(--color-success);
color: white;
}
&.preset-status-disabled {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
}
.preset-description {
color: var(--color-text-maxcontrast);
font-size: 14px;
margin: 0 0 12px 0;
line-height: 1.5;
}
.preset-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--color-text-maxcontrast);
margin-bottom: 12px;
.preset-app {
font-weight: 500;
}
}
.preset-actions {
display: flex;
justify-content: flex-end;
}
}
.webhook-info,
.webhook-requirements {
margin-top: 16px;
ul {
margin: 8px 0 0 0;
padding-left: 20px;
li {
margin: 4px 0;
}
}
}
</style>
+7 -4
View File
@@ -1,8 +1,11 @@
import Vue from 'vue'
import { createApp } from 'vue'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import App from './App.vue'
Vue.mixin({ methods: { t, n } })
const app = createApp(App)
const View = Vue.extend(App)
new View().$mount('#astrolabe')
// Add translation methods globally
app.config.globalProperties.t = t
app.config.globalProperties.n = n
app.mount('#astrolabe')
+4 -295
View File
@@ -2,305 +2,14 @@
/**
* Admin settings template for Astrolabe.
*
* Displays semantic search service status, indexing metrics, configuration,
* and provides administrative controls.
*
* @var array $_ Template parameters
* @var array $_['serverStatus'] Server status from API
* @var array $_['vectorSyncStatus'] Vector sync metrics from API
* @var string $_['serverUrl'] Configured Astrolabe service URL
* @var bool $_['apiKeyConfigured'] Whether API key is set in config.php
* @var bool $_['vectorSyncEnabled'] Whether vector sync is enabled
* Mounts the Vue.js admin settings component for async loading
* and improved UX.
*/
script('astrolabe', 'astrolabe-adminSettings');
style('astrolabe', 'astrolabe-adminSettings');
?>
<div id="mcp-admin-settings" class="section">
<h2><?php p($l->t('Astrolabe Administration')); ?></h2>
<div class="mcp-settings-info">
<p><?php p($l->t('Monitor and configure the semantic search service for your Nextcloud instance.')); ?></p>
<p><?php p($l->t('Use the "MCP Server Configuration" section above to configure the connection settings.')); ?></p>
</div>
<!-- Service Status -->
<div class="mcp-status-card">
<h3><?php p($l->t('Service Status')); ?></h3>
<table class="mcp-info-table">
<tr>
<td><strong><?php p($l->t('Version')); ?></strong></td>
<td><?php p($_['serverStatus']['version'] ?? 'Unknown'); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Uptime')); ?></strong></td>
<td>
<?php if (isset($_['serverStatus']['uptime_seconds'])): ?>
<?php
$uptime = $_['serverStatus']['uptime_seconds'];
$hours = floor($uptime / 3600);
$minutes = floor(($uptime % 3600) / 60);
p(sprintf('%d hours, %d minutes', $hours, $minutes));
?>
<?php else: ?>
<?php p($l->t('Unknown')); ?>
<?php endif; ?>
</td>
</tr>
<tr>
<td><strong><?php p($l->t('Semantic Search')); ?></strong></td>
<td>
<?php if ($_['vectorSyncEnabled']): ?>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Enabled')); ?>
</span>
<?php else: ?>
<span class="badge badge-neutral">
<?php p($l->t('Disabled')); ?>
</span>
<?php endif; ?>
</td>
</tr>
</table>
</div>
<!-- Indexing Metrics -->
<?php if ($_['vectorSyncEnabled'] && !isset($_['vectorSyncStatus']['error'])): ?>
<div class="mcp-status-card" id="vector-sync-metrics">
<h3><?php p($l->t('Indexing Metrics')); ?></h3>
<table class="mcp-info-table">
<tr>
<td><strong><?php p($l->t('Status')); ?></strong></td>
<td>
<?php
$status = $_['vectorSyncStatus']['status'] ?? 'unknown';
$statusClass = $status === 'idle' ? 'success' : ($status === 'syncing' ? 'info' : 'neutral');
?>
<span class="badge badge-<?php p($statusClass); ?>">
<?php p(ucfirst($status)); ?>
</span>
</td>
</tr>
<tr>
<td><strong><?php p($l->t('Indexed Documents')); ?></strong></td>
<td><?php p(number_format($_['vectorSyncStatus']['indexed_documents'] ?? 0)); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Pending Documents')); ?></strong></td>
<td><?php p(number_format($_['vectorSyncStatus']['pending_documents'] ?? 0)); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Last Sync')); ?></strong></td>
<td><?php p($_['vectorSyncStatus']['last_sync_time'] ?? 'Never'); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Processing Rate')); ?></strong></td>
<td><?php p(sprintf('%.1f docs/sec', $_['vectorSyncStatus']['documents_per_second'] ?? 0)); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Errors (24h)')); ?></strong></td>
<td>
<?php
$errors = $_['vectorSyncStatus']['errors_24h'] ?? 0;
if ($errors > 0): ?>
<span class="error"><?php p($errors); ?></span>
<?php else: ?>
<?php p('0'); ?>
<?php endif; ?>
</td>
</tr>
</table>
<p class="mcp-help-text">
<?php p($l->t('Metrics are updated in real-time. Refresh the page to see latest values.')); ?>
</p>
</div>
<?php elseif ($_['vectorSyncEnabled']): ?>
<div class="mcp-status-card mcp-error">
<h3><?php p($l->t('Indexing Metrics')); ?></h3>
<div class="notecard notecard-error">
<p><?php p($l->t('Failed to retrieve indexing status:')); ?></p>
<p><code><?php p($_['vectorSyncStatus']['error'] ?? 'Unknown error'); ?></code></p>
</div>
</div>
<?php endif; ?>
<!-- Search Settings -->
<?php if ($_['vectorSyncEnabled']): ?>
<div class="mcp-status-card" id="search-settings">
<h3><?php p($l->t('AI Search Provider Settings')); ?></h3>
<p class="mcp-settings-description">
<?php p($l->t('Configure the default search parameters for the AI Search provider in Nextcloud unified search.')); ?>
</p>
<form id="astrolabe-search-settings-form" class="mcp-settings-form">
<div class="mcp-form-group">
<label for="search-algorithm"><?php p($l->t('Search Algorithm')); ?></label>
<select id="search-algorithm" name="algorithm" class="mcp-select">
<option value="hybrid" <?php if ($_['searchSettings']['algorithm'] === 'hybrid') {
echo 'selected';
} ?>>
<?php p($l->t('Hybrid (Recommended)')); ?>
</option>
<option value="semantic" <?php if ($_['searchSettings']['algorithm'] === 'semantic') {
echo 'selected';
} ?>>
<?php p($l->t('Semantic Only')); ?>
</option>
<option value="bm25" <?php if ($_['searchSettings']['algorithm'] === 'bm25') {
echo 'selected';
} ?>>
<?php p($l->t('Keyword (BM25) Only')); ?>
</option>
</select>
<p class="mcp-help-text">
<?php p($l->t('Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.')); ?>
</p>
</div>
<div class="mcp-form-group">
<label for="search-fusion"><?php p($l->t('Fusion Method')); ?></label>
<select id="search-fusion" name="fusion" class="mcp-select">
<option value="rrf" <?php if ($_['searchSettings']['fusion'] === 'rrf') {
echo 'selected';
} ?>>
<?php p($l->t('RRF - Reciprocal Rank Fusion (Recommended)')); ?>
</option>
<option value="dbsf" <?php if ($_['searchSettings']['fusion'] === 'dbsf') {
echo 'selected';
} ?>>
<?php p($l->t('DBSF - Distribution-Based Score Fusion')); ?>
</option>
</select>
<p class="mcp-help-text">
<?php p($l->t('Only applies to hybrid search. RRF balances results well for most queries. DBSF may work better when keyword matches are over/under-weighted.')); ?>
</p>
</div>
<div class="mcp-form-group">
<label for="search-score-threshold">
<?php p($l->t('Minimum Score Threshold')); ?>:
<span id="score-threshold-value"><?php p($_['searchSettings']['scoreThreshold']); ?>%</span>
</label>
<input type="range"
id="search-score-threshold"
name="scoreThreshold"
min="0"
max="100"
step="5"
value="<?php p($_['searchSettings']['scoreThreshold']); ?>"
class="mcp-range" />
<p class="mcp-help-text">
<?php p($l->t('Filter out results below this relevance score. Set to 0 to show all results.')); ?>
</p>
</div>
<div class="mcp-form-group">
<label for="search-limit"><?php p($l->t('Maximum Results')); ?></label>
<input type="number"
id="search-limit"
name="limit"
min="5"
max="100"
step="5"
value="<?php p($_['searchSettings']['limit']); ?>"
class="mcp-input" />
<p class="mcp-help-text">
<?php p($l->t('Maximum number of results to return per search query (5-100).')); ?>
</p>
</div>
<div class="mcp-form-actions">
<button type="submit" class="primary">
<?php p($l->t('Save Settings')); ?>
</button>
<span id="search-settings-status" class="mcp-status-message"></span>
</div>
</form>
</div>
<?php endif; ?>
<!-- Webhook Management -->
<?php if ($_['vectorSyncEnabled']): ?>
<div class="mcp-status-card" id="webhook-presets">
<h3><?php p($l->t('Webhook Management')); ?></h3>
<p class="mcp-settings-description">
<?php p($l->t('Configure real-time synchronization for Nextcloud apps using webhooks. Webhooks provide instant updates to the MCP server when content changes.')); ?>
</p>
<div id="webhook-presets-container">
<div class="mcp-loading">
<?php p($l->t('Loading webhook presets...')); ?>
</div>
</div>
<div class="notecard notecard-info">
<p><strong><?php p($l->t('How Webhooks Work')); ?></strong></p>
<ul>
<li><?php p($l->t('Enable a preset to register webhooks for that app with the MCP server')); ?></li>
<li><?php p($l->t('When content changes in Nextcloud, webhooks notify the MCP server instantly')); ?></li>
<li><?php p($l->t('The MCP server updates its vector index in real-time for semantic search')); ?></li>
<li><?php p($l->t('Disable a preset to stop receiving updates for that app')); ?></li>
</ul>
</div>
<div class="notecard notecard-warning">
<p><strong><?php p($l->t('Requirements')); ?></strong></p>
<ul>
<li><?php p($l->t('The webhook_listeners app must be installed and enabled in Nextcloud')); ?></li>
<li><?php p($l->t('The MCP server must be reachable from your Nextcloud instance')); ?></li>
<li><?php p($l->t('You must have authorized Astrolabe with the MCP server (see Personal Settings)')); ?></li>
</ul>
</div>
</div>
<?php endif; ?>
<!-- Capabilities -->
<div class="mcp-status-card">
<h3><?php p($l->t('Capabilities')); ?></h3>
<ul class="mcp-feature-list">
<li>
<span class="icon icon-search"></span>
<strong><?php p($l->t('Semantic Search')); ?></strong>
<p><?php p($l->t('Search by meaning across Notes, Files, Calendar, and Deck using natural language queries.')); ?></p>
</li>
<?php if ($_['vectorSyncEnabled']): ?>
<li>
<span class="icon icon-category-monitoring"></span>
<strong><?php p($l->t('Vector Visualization')); ?></strong>
<p><?php p($l->t('Explore content relationships in an interactive 2D visualization.')); ?></p>
</li>
<?php endif; ?>
<li>
<span class="icon icon-user"></span>
<strong><?php p($l->t('Per-User Indexing')); ?></strong>
<p><?php p($l->t('Users control their own content indexing via Personal Settings.')); ?></p>
</li>
<li>
<span class="icon icon-toggle"></span>
<strong><?php p($l->t('Hybrid Search')); ?></strong>
<p><?php p($l->t('Combines semantic understanding with keyword matching for optimal results.')); ?></p>
</li>
</ul>
</div>
<!-- Documentation -->
<div class="mcp-status-card">
<h3><?php p($l->t('Documentation')); ?></h3>
<ul class="mcp-links">
<li>
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">
<?php p($l->t('Configuration Guide')); ?>
</a>
</li>
<li>
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server" target="_blank">
<?php p($l->t('GitHub Repository')); ?>
</a>
</li>
</ul>
</div>
<div id="astrolabe-admin-settings" class="section">
<!-- Vue component will be mounted here -->
</div>
+31 -5
View File
@@ -1,7 +1,33 @@
import { createAppConfig } from '@nextcloud/vite-config'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default createAppConfig({
main: 'src/main.js',
adminSettings: 'src/adminSettings.js',
personalSettings: 'src/personalSettings.js',
export default defineConfig({
plugins: [vue()],
build: {
outDir: '.',
emptyOutDir: false,
cssCodeSplit: false, // Bundle all CSS into entry points (Nextcloud doesn't load CSS chunks)
rollupOptions: {
input: {
'astrolabe-main': resolve(__dirname, 'src/main.js'),
'astrolabe-adminSettings': resolve(__dirname, 'src/adminSettings.js'),
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
},
output: {
entryFileNames: 'js/[name].mjs',
chunkFileNames: 'js/[name]-[hash].chunk.mjs',
assetFileNames: (assetInfo) => {
// With cssCodeSplit:false, all CSS goes to a single file
// Name it astrolabe-main.css to match Nextcloud's Util::addStyle expectation
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
return 'css/astrolabe-main.css';
}
return 'js/[name][extname]';
},
},
},
sourcemap: true,
minify: 'terser',
},
})
Generated
+1 -1
View File
@@ -1988,7 +1988,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.57.0"
version = "0.60.3"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },