Compare commits

..

262 Commits

Author SHA1 Message Date
github-actions[bot] 92f69c8dba bump: version 0.57.32 → 0.57.33 2026-02-06 14:23:11 +00:00
Chris Coutinho 6692a85007 Merge pull request #534 from cbcoutinho/renovate/uv_build-0.x
chore(deps): update dependency uv_build to >=0.10.0,<0.11.0
2026-02-06 15:22:55 +01:00
github-actions[bot] 1f09079b5a bump: version 0.57.31 → 0.57.32 2026-02-06 14:04:59 +00:00
Chris Coutinho 2535c95f4e Merge pull request #535 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.0
2026-02-06 15:04:30 +01:00
renovate-bot-cbcoutinho[bot] 4fac0ca40d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.0 2026-02-06 11:09:24 +00:00
renovate-bot-cbcoutinho[bot] 719a432a95 chore(deps): update dependency uv_build to >=0.10.0,<0.11.0 2026-02-06 11:09:13 +00:00
github-actions[bot] 6f482c9245 bump: version 0.57.30 → 0.57.31 2026-02-06 07:23:12 +00:00
Chris Coutinho a6ad3707c6 Merge pull request #513 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.30
2026-02-06 08:22:55 +01:00
github-actions[bot] b34f8d96e3 bump: version 0.57.29 → 0.57.30 2026-02-06 07:09:22 +00:00
Chris Coutinho d948f51b10 Merge pull request #532 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 43e4d70
2026-02-06 08:09:07 +01:00
renovate-bot-cbcoutinho[bot] 5eb5b5023c chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.30 2026-02-05 11:12:00 +00:00
renovate-bot-cbcoutinho[bot] 504213ae79 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 43e4d70 2026-02-05 11:11:49 +00:00
github-actions[bot] 0a3052d0d9 bump: version 0.57.28 → 0.57.29 2026-02-04 06:25:10 +00:00
Chris Coutinho 2b691f1792 Merge pull request #525 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.5
chore(deps): update docker.io/library/nextcloud:32.0.5 docker digest to 4b66e9b
2026-02-04 07:24:55 +01:00
github-actions[bot] e3da2e006c bump: version 0.57.27 → 0.57.28 2026-02-03 19:57:46 +00:00
Chris Coutinho 4539f2f486 Merge pull request #526 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 87b49ee
2026-02-03 20:57:27 +01:00
renovate-bot-cbcoutinho[bot] c85ad95faf chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 87b49ee 2026-02-03 11:12:24 +00:00
renovate-bot-cbcoutinho[bot] 60f7234908 chore(deps): update docker.io/library/nextcloud:32.0.5 docker digest to 4b66e9b 2026-02-03 11:12:18 +00:00
github-actions[bot] 1dd5698389 bump: version 0.10.0 → 0.10.1 2026-02-03 06:50:26 +00:00
github-actions[bot] 3a0096f8df bump: version 0.57.26 → 0.57.27 2026-02-03 06:50:26 +00:00
github-actions[bot] 7bcffd1e96 bump: version 0.63.0 → 0.63.1 2026-02-03 06:50:25 +00:00
Chris Coutinho 9674366312 Merge pull request #524 from rule88/master
create persistant volume in basis auth as well
2026-02-03 07:50:06 +01:00
Chris Coutinho a7581a1d1b fix(helm): add backward compatibility for legacy persistence configs
- Add helper functions to detect and use legacy persistence configs
- Legacy auth.multiUserBasic.persistence.* and qdrant.localPersistence.*
  configs continue to work but show deprecation warnings in NOTES.txt
- New dataStorage.enabled takes precedence when explicitly set
- PVC size/accessMode/storageClass values from legacy configs are honored

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 07:45:30 +01:00
Rick 0ff442d61c create persistant volume in basis auth as well 2026-02-02 12:10:53 +01:00
github-actions[bot] 96598510ee bump: version 0.57.25 → 0.57.26 2026-01-31 16:56:55 +00:00
Chris Coutinho 02cb1f5491 Merge pull request #512 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.40
2026-01-31 17:56:40 +01:00
github-actions[bot] 3856698d0a bump: version 0.57.24 → 0.57.25 2026-01-31 16:48:42 +00:00
Chris Coutinho 3a05f0cfb3 Merge pull request #500 from cbcoutinho/renovate/phpunit-phpunit-10.x-lockfile
chore(deps): update dependency phpunit/phpunit to v10.5.63
2026-01-31 17:48:27 +01:00
github-actions[bot] fe5e7f7a60 bump: version 0.57.23 → 0.57.24 2026-01-31 16:10:39 +00:00
Chris Coutinho b7257f4e59 Merge pull request #481 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 4870c12
2026-01-31 17:10:21 +01:00
renovate-bot-cbcoutinho[bot] 7cc852f0da chore(deps): update dependency phpunit/phpunit to v10.5.63 2026-01-31 11:08:40 +00:00
renovate-bot-cbcoutinho[bot] 525258be67 chore(deps): update anthropics/claude-code-action action to v1.0.40 2026-01-31 11:08:20 +00:00
renovate-bot-cbcoutinho[bot] 49bd3100ad chore(deps): update docker.io/library/nginx:alpine docker digest to 4870c12 2026-01-31 11:08:13 +00:00
github-actions[bot] 6693bab9f9 bump: version 0.57.22 → 0.57.23 2026-01-30 19:26:59 +00:00
Chris Coutinho 8e0d64f7d3 Merge branch 'master' of github.com:cbcoutinho/nextcloud-mcp-server 2026-01-30 19:26:34 +00:00
Chris Coutinho c97ffe8e47 docs(astrolabe): Add initial blog post 2026-01-30 19:17:23 +00:00
github-actions[bot] d0115170c2 bump: version 0.57.21 → 0.57.22 2026-01-30 19:14:47 +00:00
Chris Coutinho 9ec00d4de5 chore: Update screenshot names 2026-01-30 19:14:10 +00:00
github-actions[bot] 9527427782 bump: version 0.57.20 → 0.57.21 2026-01-30 14:38:25 +00:00
Chris Coutinho fbfc8b8a05 Merge pull request #514 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.40.0
2026-01-30 15:38:09 +01:00
renovate-bot-cbcoutinho[bot] e85000424d chore(deps): update helm release ollama to v1.40.0 2026-01-30 11:10:16 +00:00
github-actions[bot] 58ac60be12 bump: version 0.57.19 → 0.57.20 2026-01-29 21:55:19 +00:00
Chris Coutinho 77ef928060 Merge pull request #494 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to 9945a84
2026-01-29 22:54:56 +01:00
renovate-bot-cbcoutinho[bot] 00afac8e46 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to 9945a84 2026-01-29 11:12:00 +00:00
github-actions[bot] d22cebc69a bump: version 0.57.18 → 0.57.19 2026-01-28 20:15:42 +00:00
Chris Coutinho 151d595360 Merge pull request #515 from cbcoutinho/renovate/docker.io-library-redis-alpine
chore(deps): update docker.io/library/redis:alpine docker digest to 0804c39
2026-01-28 21:15:26 +01:00
github-actions[bot] 7e02a58546 bump: version 0.57.17 → 0.57.18 2026-01-28 12:46:37 +00:00
Chris Coutinho 25dee9bfaf Merge pull request #496 from cbcoutinho/renovate/vue-monorepo
chore(deps): update dependency vue to v3.5.27
2026-01-28 13:46:20 +01:00
github-actions[bot] f898d61077 bump: version 0.57.16 → 0.57.17 2026-01-28 12:45:31 +00:00
Chris Coutinho 0aaa3fc912 Merge pull request #468 from cbcoutinho/renovate/nextcloud-vue-9.x-lockfile
chore(deps): update dependency @nextcloud/vue to v9.4.0
2026-01-28 13:45:15 +01:00
renovate-bot-cbcoutinho[bot] 77fabccdb7 chore(deps): update dependency @nextcloud/vue to v9.4.0 2026-01-28 11:11:49 +00:00
renovate-bot-cbcoutinho[bot] 2648ef2567 chore(deps): update dependency vue to v3.5.27 2026-01-28 11:11:24 +00:00
renovate-bot-cbcoutinho[bot] 405a57649a chore(deps): update docker.io/library/redis:alpine docker digest to 0804c39 2026-01-28 11:10:42 +00:00
github-actions[bot] 252df1d398 bump: version 0.9.0 → 0.10.0 2026-01-28 07:39:10 +00:00
github-actions[bot] 0ad81a1fd8 bump: version 0.57.15 → 0.57.16 2026-01-28 07:39:10 +00:00
github-actions[bot] dce864e947 bump: version 0.62.0 → 0.63.0 2026-01-28 07:39:09 +00:00
Chris Coutinho b9f1040dd5 Merge pull request #511 from cbcoutinho/feat/background-token-refresh
feat(astrolabe): add background token refresh job
2026-01-28 08:38:50 +01:00
Chris Coutinho c7882adb24 docs: add authentication flows reference by deployment mode
Create unified documentation covering authentication flows across all five
deployment modes. Documents three communication patterns (MCP Client → MCP
Server → Nextcloud, background sync, Astrolabe → MCP Server) with ASCII
sequence diagrams and implementation references.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:38:29 +01:00
Chris Coutinho 9491d698e8 fix(astrolabe): add pagination and psalm fixes for token refresh
- Add pagination to getAllUsersWithTokens() with limit/offset params
- Update RefreshUserTokens to process users in batches of 100
- Add lock TTL documentation to withTokenLock() docstring
- Fix psalm type errors in getAccessToken() method
- Add unit tests for pagination and batched processing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:13:22 +01:00
Chris Coutinho 5b71ac3251 fix(astrolabe): add locking to prevent token refresh race condition
Adds distributed locking using Nextcloud's ILockingProvider to prevent
race conditions between background job and on-demand token refresh.

Uses double-check locking pattern:
1. Quick check without lock - return immediately if token is valid
2. Acquire exclusive lock if token needs refresh
3. Re-check after lock - another process may have refreshed
4. Refresh only if still needed
5. Graceful degradation on LockedException

Changes:
- McpTokenStorage: add ILockingProvider, withTokenLock() method
- McpTokenStorage: update getAccessToken() with locking pattern
- RefreshUserTokens: wrap refresh in withTokenLock(), catch LockedException
- Add comprehensive unit tests for locking behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:23:42 +01:00
Chris Coutinho 815a09be34 test(astrolabe): add unit tests for background token refresh
- Fix McpTokenStorageTest: add IDBConnection mock for new constructor parameter
- Add doctrine/dbal dev dependency for IQueryBuilder mock support
- Add tests for getAllUsersWithTokens() database query method
- Create RefreshUserTokensTest with comprehensive coverage:
  - Job interval configuration (15 min)
  - Token refresh threshold logic (50% lifetime)
  - issued_at tracking for accurate lifetime calculation
  - Fallback to default lifetime when issued_at missing
  - Token rotation handling
  - Error handling and logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:23:06 +01:00
Chris Coutinho c46f9eb212 fix(astrolabe): add issued_at to on-demand token refresh
Fixes missing issued_at parameter when storing tokens refreshed via
getAccessToken() callback, ensuring accurate token lifetime calculation
for the background refresh job.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:25:43 +01:00
Chris Coutinho 28219e00e7 feat(astrolabe): add background token refresh job
Prevents users from having to re-authorize Astrolabe after periods of
inactivity by proactively refreshing OAuth tokens before they expire.

Changes:
- Add RefreshUserTokens background job that runs every 15 minutes
- Add on-demand token refresh in SemanticSearchProvider (Unified Search)
- Store issued_at timestamp for accurate token lifetime calculation
- Add getAllUsersWithTokens() to query users needing refresh

The job dynamically calculates refresh threshold based on actual token
lifetime (50% remaining), working with any IdP (Nextcloud OIDC, Keycloak,
etc.) rather than relying on IdP-specific configuration.

Closes #510

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 10:34:39 +01:00
github-actions[bot] daaf460b0c bump: version 0.8.3 → 0.9.0 2026-01-26 21:02:22 +00:00
github-actions[bot] 04f05f725c bump: version 0.57.14 → 0.57.15 2026-01-26 21:02:21 +00:00
github-actions[bot] b499aa2abe bump: version 0.61.5 → 0.62.0 2026-01-26 21:02:21 +00:00
Chris Coutinho 72df7dd1eb Merge pull request #501 from cbcoutinho/feat/pymupdf-pdf-rendering
feat(astrolabe): replace PDF.js with server-side PyMuPDF rendering
2026-01-26 22:02:01 +01:00
Chris Coutinho 2e7774654b refactor(api): split management.py into domain-focused modules
Split the monolithic management.py (1988 lines) into 4 focused modules:
- management.py: Server status, user sessions, shared helpers (~520 lines)
- passwords.py: App password provisioning for BasicAuth mode (~300 lines)
- webhooks.py: Webhook registration management (~290 lines)
- visualization.py: Search and PDF preview endpoints (~810 lines)

Backward compatibility maintained via __init__.py re-exports.
Updated test imports to use new module paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:28:18 +01:00
Chris Coutinho 61ce873411 chore: Address reviewer comments and add error handling to PDF chunk viz preview endpoints 2026-01-26 21:16:31 +01:00
github-actions[bot] 0af9657fea bump: version 0.57.13 → 0.57.14 2026-01-26 19:29:37 +00:00
Chris Coutinho 8507e480d6 Merge remote-tracking branch 'origin/master' into feat/pymupdf-pdf-rendering 2026-01-26 20:29:34 +01:00
Chris Coutinho 905d18baf7 ci(claude): allow renovate bot to trigger code reviews
Add allowed_bots configuration for renovate-bot-cbcoutinho to enable
Claude Code review on dependency update PRs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:29:17 +01:00
Chris Coutinho b5e5d86790 ci(claude): allow renovate bot to trigger code reviews
Add allowed_bots configuration for renovate-bot-cbcoutinho to enable
Claude Code review on dependency update PRs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:27:17 +01:00
Chris Coutinho c35e94b0bc test(api): add unit tests for PDF preview management endpoint
Add comprehensive unit tests for the /api/v1/pdf-preview endpoint:
- Parameter validation (file_path, page, scale)
- OAuth token authentication
- PDF rendering with PyMuPDF
- Error handling (file not found, invalid page, corrupted PDF)
- Edge cases (URL-encoded paths, boundary values, missing config)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:24:33 +01:00
Chris Coutinho c09ebe99cc fix(astrolabe): resolve Psalm type errors in PDF preview code
Fix Psalm static analysis errors:
- Add return type annotations to refresh callback closures
- Use strict null comparisons instead of truthy/falsy checks
- Cast response body to string for json_decode
- Add type annotation for decoded JSON data
- Update psalm-baseline.xml to remove fixed issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:19:23 +01:00
Chris Coutinho d5544a7731 refactor(astrolabe): replace client-side PDF.js with server-side PyMuPDF rendering
Replace the client-side PDF.js viewer with server-side rendering using PyMuPDF.
This avoids CSP worker restrictions and ES private field access issues that
affected Chromium browsers.

Changes:
- Add /api/v1/pdf-preview endpoint to MCP server (management.py)
- Add pdf-preview route and controller action in Astrolabe PHP backend
- Refactor PDFViewer.vue to display server-rendered PNG images
- Remove pdfjs-dist dependency and client-side PDF loading code
- Use @nextcloud/axios for CSRF token handling in PDFViewer

The server downloads the PDF via WebDAV, renders the requested page with
PyMuPDF at the specified scale, and returns a base64-encoded PNG image.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:04:57 +01:00
Chris Coutinho bc62f2a066 fix(astrolabe): fix Psalm baseline and ESLint import order
- Update psalm-baseline.xml to match renamed OauthController.php (lowercase 'a')
- Move AlertCircle import to top of PDFViewer.vue to satisfy ESLint import/first rule

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:41:48 +01:00
Chris Coutinho 38adb96be4 fix(astrolabe): load pdfjs-dist externally to fix PDF viewer
When viewing PDF chunks in semantic search, the PDF viewer failed with
"can't access private field" errors. This was caused by:

1. CSP blocks web workers (worker-src 'none'), forcing fake worker mode
2. Vite transforms ES private fields in the bundle, but the worker file
   is untransformed, causing incompatible private field implementations
3. Vue's ref() wraps PDFDocumentProxy in a Proxy, which can't access
   ES private fields

Fixed by:
- Loading pdfjs-dist externally via script tag (avoids Vite transform)
- Creating pdfjs-loader.mjs that imports pdf.mjs and sets window.pdfjsLib
- Using Util::addScript() for CSP-compliant script loading with nonces
- Using shallowRef() instead of ref() for pdfDoc to avoid Proxy wrapper
- Setting workerSrc at runtime using OC.linkTo() for correct app path

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:08:44 +01:00
Chris Coutinho c76dd21eeb fix(astrolabe): improve error messages for authorization issues
Replace generic "Network error" with specific error messages:
- Show backend error message when available from HTTP response
- Display "Authorization required. Please complete Step 1 in
  Settings → Astrolabe." for 401 Unauthorized errors
- Show "Search service unavailable" for 503 errors
- Keep generic network error only for actual connection failures

This helps users understand when they need to complete OAuth
authorization vs when there's an actual network problem.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:21:57 +01:00
Chris Coutinho c5bf4cda8a fix(astrolabe): rename OAuthController and fix app password check
- Rename OAuthController.php to OauthController.php for consistency
- Fix Personal.php to check specifically for app password presence
  using getBackgroundSyncPassword() instead of hasBackgroundSyncAccess()
  for hybrid auth mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 13:04:23 +01:00
Chris Coutinho 0b6a6b0842 fix(tests): improve Astrolabe integration test reliability
- Replace Close button click with Escape key in app password dialog
  (h2 element was intercepting pointer events)
- Make test_users_setup fixture idempotent by checking user existence
  before creation and only tracking created users for cleanup
- Fix search results detection by removing wait for .app-content-wrapper
  CSS class that doesn't exist in Astrolabe's Vue app
- Add progress logging during results polling
- Increase polling timeout to 30 seconds for search results

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 13:03:35 +01:00
github-actions[bot] 9c4c4d4563 bump: version 0.57.12 → 0.57.13 2026-01-24 11:45:02 +00:00
Chris Coutinho 2d74b1a1fb Merge pull request #495 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.34
2026-01-24 12:44:48 +01:00
renovate-bot-cbcoutinho[bot] 26ba237142 chore(deps): update anthropics/claude-code-action action to v1.0.34 2026-01-24 11:07:19 +00:00
Chris Coutinho 7b75304c9f feat(scripts): add database query helpers for development
Add dbquery.py for MariaDB and sqlitequery.py for SQLite databases
in MCP service containers. Both scripts wrap docker compose exec to
simplify database inspection during development.

- dbquery.py: Query Nextcloud MariaDB with vertical/JSON output
- sqlitequery.py: Query MCP service SQLite DBs with service aliases
  (mcp, oauth, keycloak, basic) and column/JSON output modes
- Document both scripts in CLAUDE.md Database Inspection section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 09:26:30 +01:00
Chris Coutinho 9004e14022 Merge remote-tracking branch 'origin/master' into renovate/plotly.js-dist-min-3.x 2026-01-21 18:22:05 +01:00
Chris Coutinho e7a3dd698a fix(astrolabe): update Plotly title attributes for v3 compatibility
Plotly.js v3 removed string format for title attributes (plotly/plotly.js#7212).
All titles must now use object format: { text: "..." }

Changes:
- Main layout title: string → { text: "..." }
- Scene axis titles (xaxis, yaxis, zaxis): string → { text: "..." }
- Colorbar title: string → { text: "..." }

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:04:49 +01:00
github-actions[bot] c12007c342 bump: version 0.57.11 → 0.57.12 2026-01-20 14:26:42 +00:00
Chris Coutinho f37cf8a159 Merge pull request #492 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.5
chore(deps): update docker.io/library/nextcloud:32.0.5 docker digest to 11a3a4f
2026-01-20 15:26:23 +01:00
github-actions[bot] 07f2952599 bump: version 0.57.10 → 0.57.11 2026-01-20 13:27:05 +00:00
Chris Coutinho 6cf916876a Merge pull request #493 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.38.0
2026-01-20 14:26:48 +01:00
renovate-bot-cbcoutinho[bot] 27b11eabf9 chore(deps): update helm release ollama to v1.38.0 2026-01-20 11:12:04 +00:00
renovate-bot-cbcoutinho[bot] da31dec33e chore(deps): update docker.io/library/nextcloud:32.0.5 docker digest to 11a3a4f 2026-01-20 11:11:24 +00:00
github-actions[bot] a61bcccdac bump: version 0.57.9 → 0.57.10 2026-01-19 12:42:51 +00:00
Chris Coutinho 774de68966 Merge pull request #488 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 5e2dbd4
2026-01-19 13:42:35 +01:00
github-actions[bot] 44b77875f7 bump: version 0.57.8 → 0.57.9 2026-01-19 12:41:25 +00:00
Chris Coutinho 5469cf05f0 Merge pull request #490 from cbcoutinho/renovate/docker.io-library-nextcloud-32.x
chore(deps): update docker.io/library/nextcloud docker tag to v32.0.5
2026-01-19 13:41:10 +01:00
renovate-bot-cbcoutinho[bot] 6832ae1198 fix(deps): update dependency plotly.js-dist-min to v3 2026-01-18 11:14:27 +00:00
github-actions[bot] 619faaf1df bump: version 0.57.7 → 0.57.8 2026-01-18 10:57:38 +00:00
Chris Coutinho 34387ff202 Merge pull request #489 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.30
2026-01-18 11:57:23 +01:00
github-actions[bot] 76d3174264 bump: version 0.8.2 → 0.8.3 2026-01-17 20:38:00 +00:00
github-actions[bot] 723337754f bump: version 0.57.6 → 0.57.7 2026-01-17 20:38:00 +00:00
github-actions[bot] 2d79fc6c3d bump: version 0.61.4 → 0.61.5 2026-01-17 20:38:00 +00:00
Chris Coutinho 80972f5d37 Merge pull request #487 from cbcoutinho/fix/astrolabe-token-refresh-internal-url
fix(astrolabe): use internal URL for OAuth token refresh
2026-01-17 21:37:40 +01:00
Chris Coutinho f0ade4ad28 refactor(astrolabe): add PHP property types to fix Psalm errors
Add explicit property type declarations to IdpTokenRefresher,
CredentialsController, OAuthController, and McpServerClient classes.
This improves type safety and allows Psalm to properly infer types,
eliminating MissingPropertyType and many MixedMethodCall errors.

Also adds IClient import where needed and validates getSystemValue
returns to ensure string types before use.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 21:24:56 +01:00
Chris Coutinho 737f10f190 fix(astrolabe): improve token refresh error handling and validation
- Extract magic number to TOKEN_EXPIRY_BUFFER_SECONDS constant
- Add URL validation for astrolabe_internal_url with fallback
- Warn when internal URL uses external port mapping (misconfiguration)
- Differentiate HTTP error handling by status code:
  - Network errors (LocalServerException): warning level
  - Auth errors (401/403): error level (token invalid)
  - Server errors (500+): warning level (transient)
- Reduce log level for IdP selection messages to debug
- Add integration tests for credential storage, isolation, and revoke/reprovision

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 19:43:15 +01:00
Chris Coutinho 813e9a60cb chore: Run npm install 2026-01-17 15:03:33 +01:00
Chris Coutinho 5c25b87cbe chore(astrolabe): remove duplicate .github workflows
GitHub workflows should be defined only in the root .github directory,
not in the subproject directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:26:20 +01:00
Chris Coutinho e48c5fa9a2 fix(astrolabe): delete stale tokens when refresh fails
- Delete stored token when refresh callback fails or returns null
- Delete stored token when expired with no refresh callback available
- Fix test namespaces (Service → OCA\Astrolabe\Tests\Unit\Service)
- Update tests to verify token deletion on refresh failure

Prevents repeated refresh attempts with stale tokens that will never
succeed, improving error handling and reducing unnecessary API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:21:53 +01:00
Chris Coutinho 303efeddf7 refactor(astrolabe): upgrade to @nextcloud/vue 9.3.3 API
- Replace NcCheckboxRadioSwitch :checked with :model-value
- Replace NcCheckboxRadioSwitch @update:checked with @update:model-value
- Replace NcButton type="primary|secondary|tertiary" with variant prop
- Bump @nextcloud/vue minimum version to ^9.3.3

These changes address deprecated APIs removed in @nextcloud/vue v9.0.0:
- :checked/:update:checked was replaced by v-model/modelValue pattern
- type prop for button variants was replaced by variant prop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:09:27 +01:00
renovate-bot-cbcoutinho[bot] c9bf3d0b52 chore(deps): update docker.io/library/nextcloud docker tag to v32.0.5 2026-01-17 11:11:53 +00:00
renovate-bot-cbcoutinho[bot] 9f64609722 chore(deps): update anthropics/claude-code-action action to v1.0.30 2026-01-17 11:11:35 +00:00
renovate-bot-cbcoutinho[bot] 89becbb92b chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 5e2dbd4 2026-01-17 11:11:30 +00:00
Chris Coutinho fef13a6d3d test(astrolabe): add comprehensive unit tests for token refresh and storage
Add unit tests addressing reviewer feedback on test coverage gaps:

IdpTokenRefresher::refreshAccessToken() tests:
- Token refresh with internal Nextcloud OIDC
- Token refresh with external IdP (Keycloak)
- Error handling: missing client_secret, missing MCP URL
- Error handling: invalid responses, HTTP exceptions
- Token rotation validation (missing refresh_token in response)

McpTokenStorage tests (multi-user basic auth):
- OAuth token storage, retrieval, deletion
- Token expiration checks with 60-second buffer
- getAccessToken with automatic refresh callback
- App password storage for background sync
- hasBackgroundSyncAccess() for both OAuth and app passwords
- Background sync type detection and timestamp tracking

Test coverage: 41 tests, 76 assertions (up from 5 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 11:00:45 +01:00
Chris Coutinho c4973290a6 fix(astrolabe): resolve CI failures for code quality checks
- Fix PHP CS Fixer issues (single quotes, indentation)
- Add typed property declarations to ApiController
- Add Psalm baseline to suppress 517 pre-existing errors
- Fix workflow name references (astroglobe → astrolabe)

The CI workflow was previously watching a non-existent path and never
ran. After fixing the path trigger, these pre-existing code quality
issues were discovered. The Psalm baseline allows CI to pass while
tracking technical debt for incremental resolution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 10:56:36 +01:00
Chris Coutinho c018268681 docs(astrolabe): add config docs and unit tests for internal URL
Address PR #487 reviewer feedback:

- Add documentation for `astrolabe_internal_url` config option
- Add unit tests for `IdpTokenRefresher::getNextcloudBaseUrl()`
- Fix CI workflow paths (astroglobe -> astrolabe)
- Add PHPUnit job to CI workflow for PHP 8.1, 8.2, 8.3
- Remove obsolete ApiTest that tested non-existent method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 22:24:43 +01:00
Chris Coutinho 79cfb65590 fix(astrolabe): use internal URL for OAuth token refresh
The IdpTokenRefresher was incorrectly using overwrite.cli.url (the
external URL like http://localhost:8080) for internal token refresh
requests. This URL is not accessible from inside Docker containers
since port 8080 is only mapped on the host machine.

Changed getNextcloudBaseUrl() to:
- Always use http://localhost (internal port 80) by default
- Added optional astrolabe_internal_url config for custom setups
- Removed overwrite.cli.url usage (intended for external URLs only)

This fixes 401 errors in Astrolabe semantic search when OAuth tokens
need to be refreshed in containerized deployments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:42:54 +01:00
github-actions[bot] 9750845092 bump: version 0.57.5 → 0.57.6 2026-01-16 17:14:58 +00:00
Chris Coutinho 7e8171132b Merge pull request #484 from cbcoutinho/renovate/docker.io-library-mariadb-lts
chore(deps): update docker.io/library/mariadb:lts docker digest to 345fa26
2026-01-16 18:14:40 +01:00
Chris Coutinho 910792178b Merge pull request #485 from cbcoutinho/renovate/docker.io-library-nextcloud-32.x
chore(deps): update docker.io/library/nextcloud docker tag to v32.0.4
2026-01-16 18:14:29 +01:00
Chris Coutinho 80c5647f3e Merge pull request #486 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.26
2026-01-16 18:14:21 +01:00
renovate-bot-cbcoutinho[bot] a306549907 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.26 2026-01-16 11:11:51 +00:00
renovate-bot-cbcoutinho[bot] 295e3d2783 chore(deps): update docker.io/library/nextcloud docker tag to v32.0.4 2026-01-16 11:11:33 +00:00
renovate-bot-cbcoutinho[bot] 47dcdf8b61 chore(deps): update docker.io/library/mariadb:lts docker digest to 345fa26 2026-01-16 11:11:15 +00:00
github-actions[bot] 8c6ae9ff33 bump: version 0.57.4 → 0.57.5 2026-01-16 10:28:00 +00:00
Chris Coutinho 04fee00a0b Merge pull request #482 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.1
2026-01-16 11:27:39 +01:00
github-actions[bot] 9e1fc1ebeb bump: version 0.8.1 → 0.8.2 2026-01-16 09:54:03 +00:00
github-actions[bot] 6eceefdacc bump: version 0.57.3 → 0.57.4 2026-01-16 09:54:03 +00:00
github-actions[bot] b147814cc4 bump: version 0.61.3 → 0.61.4 2026-01-16 09:54:02 +00:00
Chris Coutinho 5a58c81626 Merge pull request #483 from cbcoutinho/fix/astrolabe-oauth-hybrid-mode
fix(astrolabe): fix OAuth flow for hybrid mode
2026-01-16 10:53:45 +01:00
Chris Coutinho 1cc460b0d8 fix(astrolabe): Address reviewer feedback for hybrid mode
Addresses code review feedback:

Personal.php:
- Consolidate template variables to use camelCase consistently
- Remove duplicate snake_case variables (auth_mode, supports_app_passwords)
- Add oauthUrl to standard OAuth mode parameters (fixes fallback issue)
- Add requesttoken for CSRF protection

personal.php (template):
- Use null coalescing for safe variable access
- Reuse computed $isHybridMode variable instead of duplicate check
- Remove complex fallback URL logic (oauthUrl now always provided)

IdpTokenRefresher.php:
- Use Nextcloud's overwrite.cli.url config when available
- Fall back to http://localhost for container deployments
- Better supports non-containerized environments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 10:44:52 +01:00
Chris Coutinho 104a2ec9e3 test: Add unit tests for status endpoint OIDC config
Add unit tests for /api/v1/status endpoint focusing on OIDC config:
- Test hybrid mode (multi_user_basic + enable_offline_access) returns OIDC
- Test pure multi_user_basic mode without offline_access omits OIDC
- Test OAuth mode returns OIDC config
- Test single-user BasicAuth mode omits OIDC config
- Test partial OIDC config (only discovery_url or only issuer)

Also updates docs/authentication.md with Astrolabe hybrid mode setup:
- Two-step credential setup (OAuth + app password)
- Technical details for each credential type
- Request direction table explaining why two credentials needed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 10:43:59 +01:00
Chris Coutinho e87ae56041 fix(astrolabe): Fix NcSelect options and CSS loading
- Use :input-label prop for NcSelect field labels instead of :label
  (the :label prop sets the option label property key, not the visible label)
- Fix CSS loading in admin.php and personal.php templates to use
  astrolabe-main (the bundled CSS file)
- Update minimum Nextcloud version to 31 (required for Vue 3)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:21:22 +01:00
Chris Coutinho c95459234b fix(astrolabe): fix OAuth flow and settings UI for hybrid mode
In hybrid mode (multi_user_basic + offline_access), users need BOTH:
- OAuth token for Astrolabe→MCP API calls
- App password for MCP→Nextcloud background sync

Changes:
- Personal.php: Pass correct oauthUrl pointing to Astrolabe's OAuth
  controller instead of MCP server's browser OAuth. Check both OAuth
  token AND app password status in hybrid mode.
- personal.php template: Show two-step workflow UI requiring both
  credentials before showing "Active" status. Each step shows
  completion badges.
- IdpTokenRefresher.php: Use http://localhost for internal token
  refresh requests (consistent with OAuthController). External URLs
  like localhost:8080 don't work from inside the container.

Fixes 401 errors when searching in Astrolabe with hybrid deployment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:14:00 +01:00
Chris Coutinho f16f852b23 fix(api): return OIDC config in hybrid mode for Astrolabe OAuth flow
The /api/v1/status endpoint now returns OIDC configuration (discovery_url,
issuer) when running in hybrid mode (multi_user_basic + offline_access),
not just in pure OAuth mode.

This allows Astrolabe to discover the IdP and complete the OAuth flow
for obtaining tokens to call MCP server management APIs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:13:50 +01:00
github-actions[bot] b93d7bd19b bump: version 0.57.2 → 0.57.3 2026-01-15 13:34:11 +00:00
Chris Coutinho 9a69cef815 Merge pull request #474 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to b865818
2026-01-15 14:33:56 +01:00
github-actions[bot] 2424afbdda bump: version 0.8.0 → 0.8.1 2026-01-15 11:23:42 +00:00
github-actions[bot] 0a987467b5 bump: version 0.57.1 → 0.57.2 2026-01-15 11:23:42 +00:00
github-actions[bot] ab6f7ca0b2 bump: version 0.61.2 → 0.61.3 2026-01-15 11:23:41 +00:00
Chris Coutinho 42fa33d0bf Merge pull request #480 from cbcoutinho/fix/astrolabe-vue3-bindings
fix(astrolabe): update Vue component bindings for Vue 3 compatibility
2026-01-15 12:23:21 +01:00
Chris Coutinho 006a3d95d6 fix(astrolabe): address review feedback for Vue 3 bindings
- Change limit initialization from string '20' to number 20 in App.vue
- Update AdminSettings.vue NcTextField to use v-model instead of legacy
  :value/@update:value bindings
- Update AdminSettings.vue NcSelect components to use :model-value with
  computed getters and @update:model-value for proper object-to-id
  conversion (same pattern as App.vue)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 12:16:08 +01:00
renovate-bot-cbcoutinho[bot] 1835965f44 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.1 2026-01-15 11:11:00 +00:00
Chris Coutinho cb4e8acd9f fix(astrolabe): update Vue component bindings for Vue 3 compatibility
The astrolabe app was using Vue 2 style bindings that don't work with
@nextcloud/vue 9.x and Vue 3:

- NcTextField: Changed from :value/@update:value to v-model
- NcSelect: Changed from v-model (with computed prop) to
  :model-value/@update:model-value

The legacy :value and @update:value props were being ignored because
@nextcloud/vue 9.x components use modelValue/update:modelValue internally.
This caused the search button to remain disabled and the algorithm
dropdown to be unresponsive.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 12:06:20 +01:00
github-actions[bot] 02418a9531 bump: version 0.57.0 → 0.57.1 2026-01-15 09:00:41 +00:00
github-actions[bot] f89151d099 bump: version 0.61.1 → 0.61.2 2026-01-15 09:00:41 +00:00
Chris Coutinho dc86386bf8 Merge pull request #479 from cbcoutinho/fix/helm-version-bump-on-app-change
fix(ci): bump helm chart version when MCP appVersion changes
2026-01-15 10:00:19 +01:00
Chris Coutinho 929c40709a fix(ci): bump helm chart version when MCP appVersion changes
When the MCP server version is bumped, the Helm chart's appVersion is
updated but the chart version was not. This caused users to not see
new chart releases when the app version changed.

Now the Helm chart version is bumped (PATCH) whenever:
1. There are helm-scoped commits (existing behavior)
2. OR when the MCP server version is bumped (new behavior)

This ensures Helm users always get notified of new releases containing
updated app versions.
2026-01-15 09:59:09 +01:00
github-actions[bot] a60560256d bump: version 0.7.2 → 0.8.0 2026-01-15 08:50:54 +00:00
github-actions[bot] aa583ab973 bump: version 0.61.0 → 0.61.1 2026-01-15 08:50:53 +00:00
Chris Coutinho 4103924b83 Merge pull request #478 from cbcoutinho/fix/astrolabe-appname-define
fix(astrolabe): define appName and appVersion for @nextcloud/vue
2026-01-15 09:50:32 +01:00
Chris Coutinho c192bd2ec9 chore: update docker-compose.yml env var names for semantic search 2026-01-15 09:48:38 +01:00
Chris Coutinho 2005d2841f Merge pull request #476 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to d75c4b6
2026-01-15 09:12:46 +01:00
Chris Coutinho c6295b48a5 fix(astrolabe): define appName and appVersion for @nextcloud/vue
The @nextcloud/vue library (v9.x) requires appName and appVersion to be
defined as global constants at build time. Without these, the library
logs an error: "The '@nextcloud/vue' library was used without setting /
replacing the 'appName'."

This fix reads the app ID and version from appinfo/info.xml and injects
them via Vite's define option, matching how @nextcloud/webpack-vue-config
handles this for webpack-based apps.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:59:31 +01:00
Chris Coutinho 7444c73a5a Merge pull request #475 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 66d420c
2026-01-15 07:49:40 +01:00
Chris Coutinho cf0781d2fe bump: version 0.56.2 → 0.57.0 2026-01-15 07:32:46 +01:00
Chris Coutinho 6681cd0603 Merge pull request #477 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.25
2026-01-15 07:22:06 +01:00
github-actions[bot] c305a549d3 bump: version 0.60.4 → 0.61.0 2026-01-14 19:52:32 +00:00
Chris Coutinho 1f1dd94598 Merge pull request #473 from cbcoutinho/fix/multi-user-basicauth-app-password-storage
fix(auth): Store app passwords locally for multi-user BasicAuth background sync
2026-01-14 20:52:12 +01:00
Chris Coutinho 01ad2b3d21 refactor: Use get_settings() for vector sync enabled check
Replace direct os.getenv() calls with get_settings().vector_sync_enabled
to ensure consistent behavior with both VECTOR_SYNC_ENABLED (deprecated)
and ENABLE_SEMANTIC_SEARCH environment variables.

Also add webhook management documentation guide.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:30:51 +01:00
Chris Coutinho e4cddef343 fix: Add missing annotations for deck remove/unassign operations
- Add destructiveHint=True to deck_remove_label_from_card and
  deck_unassign_user_from_card (ADR-017 compliance)
- Set idempotentHint=True since remove operations produce same end state
- Update test_annotations.py to exclude nc_webdav_create_directory from
  non-idempotent check (MKCOL is idempotent by design - returns 405 if exists)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:02:20 +01:00
Chris Coutinho f15baefe7e feat: Add rate limiting and extract helpers for app password endpoints
Security improvements:
- Add in-memory rate limiter for app password provisioning (5 attempts/hour/user)
- Returns 429 Too Many Requests with Retry-After header when limit exceeded
- Rate limiting is per-user to prevent cross-user DoS

Code quality improvements:
- Extract _extract_basic_auth() helper to reduce duplication across 3 endpoints
- Move base64, re imports to module level
- Add APP_PASSWORD_PATTERN constant for regex validation
- Add NEXTCLOUD_VALIDATION_TIMEOUT constant (10s)

Test coverage:
- Add test_provision_app_password_rate_limiting
- Add test_rate_limiting_is_per_user
- Add autouse fixture to clear rate limit state between tests
- Total: 15 tests for management API endpoints

Addresses reviewer feedback on PR #473.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:02:00 +01:00
renovate-bot-cbcoutinho[bot] 585ed46f2d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.25 2026-01-14 11:12:07 +00:00
renovate-bot-cbcoutinho[bot] dbbbab5320 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to d75c4b6 2026-01-14 11:11:49 +00:00
renovate-bot-cbcoutinho[bot] e5844b3da8 chore(deps): update docker.io/library/nginx:alpine docker digest to 66d420c 2026-01-14 11:11:44 +00:00
renovate-bot-cbcoutinho[bot] fdbf88831a chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to b865818 2026-01-14 11:11:39 +00:00
Chris Coutinho 6affad1c8b refactor: Extract storage helper and improve PHP error handling
Management API:
- Extract _get_app_password_storage() helper function
- Reduces code duplication across 3 endpoints
- Adds TYPE_CHECKING import for type hints

PHP CredentialsController:
- Add partial_success field to distinguish full vs partial success
- Add local_storage and mcp_sync boolean fields for clarity
- Rename 'warning' to 'mcp_error' for consistency
- Improves UI feedback when MCP server sync fails

Response structure now clearly indicates:
- Full success: partial_success=false, local_storage=true, mcp_sync=true
- Partial success: partial_success=true, local_storage=true, mcp_sync=false
- Full failure: success=false (unchanged)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:50:34 +01:00
Chris Coutinho 370c3ff444 test: Add comprehensive tests for app password storage and provisioning
- Add 12 unit tests for RefreshTokenStorage app password methods
  - Basic CRUD operations (store, get, delete)
  - Encryption verification (passwords encrypted at rest)
  - Error handling (missing encryption key, wrong key)
  - Multi-user independence

- Add 13 unit tests for Management API endpoints
  - POST /api/v1/users/{user_id}/app-password provisioning
  - GET /api/v1/users/{user_id}/app-password status
  - DELETE /api/v1/users/{user_id}/app-password deletion
  - Auth validation (BasicAuth, username matching)
  - Nextcloud credential validation

- Rewrite 10 integration tests for new architecture
  - Remove AstrolabeClient/OAuth dependency
  - Use local RefreshTokenStorage for app passwords
  - Test BasicAuth and OAuth mode separation
  - Test NotProvisionedError scenarios

Addresses reviewer feedback on PR #473 requiring test coverage.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:44:23 +01:00
Chris Coutinho e486e92f91 fix(auth): Store app passwords locally for multi-user BasicAuth background sync
Previously, the multi-user BasicAuth mode attempted to retrieve app passwords
via OAuth client_credentials grant, which Nextcloud OIDC doesn't support.

This fix implements local storage for app passwords:
- Add app_passwords table via Alembic migration (002)
- Add store/get/delete methods to RefreshTokenStorage
- Add management API endpoints for app password provisioning:
  - POST /api/v1/users/{user_id}/app-password
  - GET /api/v1/users/{user_id}/app-password
  - DELETE /api/v1/users/{user_id}/app-password
- Update oauth_sync.py to read from local storage
- Update Astrolabe to send app passwords to MCP server after validation
- Add app-hook to configure mcp_server_url in Nextcloud

The flow is now:
1. User creates app password in Nextcloud Security settings
2. User enters it in Astrolabe Personal Settings
3. Astrolabe validates against Nextcloud, then sends to MCP server
4. MCP server stores encrypted app password locally
5. Background sync uses locally stored password

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:44:11 +01:00
Chris Coutinho 7465e962d4 Merge pull request #472 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 7325cf2
2026-01-13 13:48:28 +01:00
Chris Coutinho 99fe764c5e Merge pull request #471 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 1a75afc
2026-01-13 13:46:27 +01:00
renovate-bot-cbcoutinho[bot] 46f896b526 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 7325cf2 2026-01-13 11:11:33 +00:00
renovate-bot-cbcoutinho[bot] a61572e8ef chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 1a75afc 2026-01-13 11:11:28 +00:00
github-actions[bot] a474996df4 bump: version 0.60.3 → 0.60.4 2026-01-12 12:37:11 +00:00
Chris Coutinho 5d6dd5ad38 Merge pull request #470 from cbcoutinho/fix/469-deck-reorder-card
fix(deck): use correct endpoint for reorder_card to fix cross-stack moves
2026-01-12 13:36:51 +01:00
Chris Coutinho 21e4d3effd fix(deck): use correct endpoint for reorder_card to fix cross-stack moves
The reorder_card method was using the API route
/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/reorder
which has a parameter conflict: the URL's {stackId} (current stack)
overrides the body's stackId (target stack) in Nextcloud's routing.

This caused cards to stay in their original stack even when the API
reported success.

Switched to the non-API route /cards/{cardId}/reorder which correctly
reads stackId from the request body, matching the behavior of the
working curl command reported in the issue.

Also added the required OCS-APIRequest headers that were missing.

Fixes #469

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:29:03 +01:00
Chris Coutinho 817df43af1 Merge pull request #465 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 271a9c9
2026-01-10 21:40:04 +01:00
Chris Coutinho 906b9d892c Merge pull request #467 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.24
2026-01-10 21:39:50 +01:00
Chris Coutinho 534723c9f6 Merge pull request #466 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to c083c37
2026-01-10 21:39:26 +01:00
renovate-bot-cbcoutinho[bot] 1d5832ed3a chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.24 2026-01-10 11:10:53 +00:00
renovate-bot-cbcoutinho[bot] 844bd589e0 chore(deps): update docker.io/library/nginx:alpine docker digest to c083c37 2026-01-10 11:10:49 +00:00
renovate-bot-cbcoutinho[bot] 127af15623 chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 271a9c9 2026-01-10 11:10:43 +00:00
Chris Coutinho ff5fc5d5b2 Merge pull request #427 from cbcoutinho/renovate/icewind1991-nextcloud-version-matrix-1.x
chore(deps): update icewind1991/nextcloud-version-matrix action to v1.3.1
2026-01-09 12:40:16 +01:00
Chris Coutinho 158865d99f Merge pull request #463 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to 1b8ee3b
2026-01-09 12:30:25 +01:00
Chris Coutinho 94674eca27 Update .github/workflows/claude.yml 2026-01-09 12:30:20 +01:00
Chris Coutinho a8b5d6e701 Update .github/workflows/claude-code-review.yml 2026-01-09 12:30:14 +01:00
Chris Coutinho e0675b2127 Merge pull request #405 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 8491795
2026-01-09 12:28:48 +01:00
Chris Coutinho 86582bdb8f Merge pull request #464 from cbcoutinho/renovate/docker.io-qdrant-qdrant-1.x
chore(deps): update docker.io/qdrant/qdrant docker tag to v1.16.3
2026-01-09 12:28:29 +01:00
renovate-bot-cbcoutinho[bot] dc8009a785 chore(deps): update docker.io/qdrant/qdrant docker tag to v1.16.3 2026-01-09 11:14:26 +00:00
renovate-bot-cbcoutinho[bot] b5e658e1ff chore(deps): update docker.io/library/nginx:alpine docker digest to 8491795 2026-01-09 11:14:22 +00:00
renovate-bot-cbcoutinho[bot] 6a19c2d136 chore(deps): update anthropics/claude-code-action digest to 1b8ee3b 2026-01-09 11:14:15 +00:00
Chris Coutinho 99e359ffbf Merge pull request #374 from cbcoutinho/renovate/qdrant-qdrant-replacement
chore(deps): replace qdrant/qdrant docker tag with docker.io/qdrant/qdrant
2026-01-08 13:17:17 +01:00
Chris Coutinho f16f4e8cb5 Merge pull request #461 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to db5fcc8
2026-01-08 13:17:03 +01:00
Chris Coutinho 8597f2a272 Merge pull request #462 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.37.0
2026-01-08 13:16:35 +01:00
renovate-bot-cbcoutinho[bot] 11f67e2bc4 chore(deps): update helm release ollama to v1.37.0 2026-01-08 11:11:18 +00:00
renovate-bot-cbcoutinho[bot] 2e49a16e49 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to db5fcc8 2026-01-08 11:10:49 +00:00
renovate-bot-cbcoutinho[bot] 713fddeaa5 chore(deps): replace qdrant/qdrant docker tag with docker.io/qdrant/qdrant 2026-01-08 11:10:22 +00:00
Chris Coutinho 0dfefb0516 Merge pull request #459 from cbcoutinho/renovate/hoverkraft-tech-compose-action-2.x
chore(deps): update hoverkraft-tech/compose-action action to v2.4.3
2026-01-07 19:55:20 +01:00
Chris Coutinho 63d2aeaa43 Merge pull request #460 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.2.0
2026-01-07 19:52:15 +01:00
renovate-bot-cbcoutinho[bot] 07f0a7c0dc chore(deps): update astral-sh/setup-uv action to v7.2.0 2026-01-07 11:12:25 +00:00
renovate-bot-cbcoutinho[bot] 84bde6d5ed chore(deps): update hoverkraft-tech/compose-action action to v2.4.3 2026-01-07 11:12:10 +00:00
Chris Coutinho 9695f8a6d7 Merge pull request #457 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to c9ec2b0
2026-01-07 08:29:06 +01:00
Chris Coutinho a2c410e8d2 Merge pull request #458 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.0
2026-01-07 08:14:01 +01:00
Chris Coutinho 271b5f6155 Merge pull request #451 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.22
2026-01-07 07:48:28 +01:00
renovate-bot-cbcoutinho[bot] ba4f7c1429 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.0 2026-01-06 11:11:19 +00:00
renovate-bot-cbcoutinho[bot] c763e96596 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.22 2026-01-06 11:10:52 +00:00
renovate-bot-cbcoutinho[bot] 23e9cbaec5 chore(deps): update anthropics/claude-code-action digest to c9ec2b0 2026-01-06 11:10:45 +00:00
Chris Coutinho ddd5defa40 Merge pull request #406 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 0ddb0f9
2026-01-03 13:17:23 -06:00
Chris Coutinho 723dcc524d Merge pull request #450 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to a75662d
2026-01-03 09:05:41 -06:00
Chris Coutinho 46eba0a693 Merge pull request #456 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to efbcfaf
2026-01-03 09:05:29 -06:00
renovate-bot-cbcoutinho[bot] b61980a623 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to efbcfaf 2026-01-03 11:12:11 +00:00
renovate-bot-cbcoutinho[bot] 65cc894e21 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to a75662d 2026-01-03 11:12:06 +00:00
renovate-bot-cbcoutinho[bot] 700996e100 chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 0ddb0f9 2026-01-02 11:10:19 +00:00
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] a987643f8e chore(deps): update icewind1991/nextcloud-version-matrix action to v1.3.1 2025-12-21 11:09:21 +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
157 changed files with 17619 additions and 8123 deletions
@@ -1,24 +1,24 @@
# Consolidated CI workflow for Astroglobe Nextcloud app
# Consolidated CI workflow for Astrolabe Nextcloud app
#
# Runs on PRs that modify the astroglobe directory
# Runs on PRs that modify the astrolabe directory
# Based on Nextcloud app skeleton workflows
#
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
# SPDX-License-Identifier: MIT
name: Astroglobe CI
name: Astrolabe CI
on:
pull_request:
paths:
- 'third_party/astroglobe/**'
- '.github/workflows/astroglobe-ci.yml'
- 'third_party/astrolabe/**'
- '.github/workflows/astrolabe-ci.yml'
permissions:
contents: read
concurrency:
group: astroglobe-ci-${{ github.head_ref || github.run_id }}
group: astrolabe-ci-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
@@ -37,18 +37,18 @@ jobs:
with:
filters: |
frontend:
- 'third_party/astroglobe/src/**'
- 'third_party/astroglobe/package.json'
- 'third_party/astroglobe/package-lock.json'
- 'third_party/astroglobe/vite.config.js'
- 'third_party/astroglobe/**/*.js'
- 'third_party/astroglobe/**/*.ts'
- 'third_party/astroglobe/**/*.vue'
- 'third_party/astrolabe/src/**'
- 'third_party/astrolabe/package.json'
- 'third_party/astrolabe/package-lock.json'
- 'third_party/astrolabe/vite.config.js'
- 'third_party/astrolabe/**/*.js'
- 'third_party/astrolabe/**/*.ts'
- 'third_party/astrolabe/**/*.vue'
php:
- 'third_party/astroglobe/lib/**'
- 'third_party/astroglobe/appinfo/**'
- 'third_party/astroglobe/composer.json'
- 'third_party/astroglobe/psalm.xml'
- 'third_party/astrolabe/lib/**'
- 'third_party/astrolabe/appinfo/**'
- 'third_party/astrolabe/composer.json'
- 'third_party/astrolabe/psalm.xml'
# Node.js build and lint
node-build:
@@ -58,7 +58,7 @@ jobs:
name: Node.js build
defaults:
run:
working-directory: third_party/astroglobe
working-directory: third_party/astrolabe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -67,7 +67,7 @@ jobs:
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
path: third_party/astroglobe
path: third_party/astrolabe
fallbackNode: '^20'
fallbackNpm: '^10'
@@ -99,7 +99,7 @@ jobs:
name: ESLint
defaults:
run:
working-directory: third_party/astroglobe
working-directory: third_party/astrolabe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -108,7 +108,7 @@ jobs:
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
path: third_party/astroglobe
path: third_party/astrolabe
fallbackNode: '^20'
fallbackNpm: '^10'
@@ -137,7 +137,7 @@ jobs:
name: Stylelint
defaults:
run:
working-directory: third_party/astroglobe
working-directory: third_party/astrolabe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -146,7 +146,7 @@ jobs:
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
path: third_party/astroglobe
path: third_party/astrolabe
fallbackNode: '^20'
fallbackNpm: '^10'
@@ -175,7 +175,7 @@ jobs:
name: PHP CS Fixer
defaults:
run:
working-directory: third_party/astroglobe
working-directory: third_party/astrolabe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -184,7 +184,7 @@ jobs:
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: third_party/astroglobe/appinfo/info.xml
filename: third_party/astrolabe/appinfo/info.xml
- name: Set up php${{ steps.versions.outputs.php-min }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
@@ -212,7 +212,7 @@ jobs:
name: Psalm
defaults:
run:
working-directory: third_party/astroglobe
working-directory: third_party/astrolabe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -221,7 +221,7 @@ jobs:
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: third_party/astroglobe/appinfo/info.xml
filename: third_party/astrolabe/appinfo/info.xml
- name: Set up php${{ steps.versions.outputs.php-min }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
@@ -242,7 +242,7 @@ jobs:
id: ocp-versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: third_party/astroglobe/appinfo/info.xml
filename: third_party/astrolabe/appinfo/info.xml
- name: Install OCP for static analysis
run: |
@@ -253,14 +253,62 @@ jobs:
- name: Run Psalm
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
# PHPUnit Tests
phpunit:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.php != 'false'
defaults:
run:
working-directory: third_party/astrolabe
strategy:
matrix:
php-versions: ['8.1', '8.2', '8.3']
name: PHPUnit (PHP ${{ matrix.php-versions }})
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up PHP ${{ matrix.php-versions }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ matrix.php-versions }}
extensions: ctype, curl, dom, gd, iconv, intl, json, mbstring, openssl, posix, sqlite, xml, zip
coverage: none
ini-file: development
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: |
composer remove nextcloud/ocp --dev || true
composer i
- name: Get OCP version matrix
id: ocp-versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: third_party/astrolabe/appinfo/info.xml
- name: Install OCP for testing
run: |
OCP_VERSION=$(echo '${{ steps.ocp-versions.outputs.ocp-matrix }}' | jq -r '.include[0]."ocp-version"')
composer require --dev "nextcloud/ocp:$OCP_VERSION" --ignore-platform-reqs --with-dependencies
- name: Run PHPUnit
run: composer run test:unit
# Summary job
summary:
permissions:
contents: none
runs-on: ubuntu-latest
needs: [changes, node-build, eslint, stylelint, php-cs, psalm]
needs: [changes, node-build, eslint, stylelint, php-cs, psalm, phpunit]
if: always()
name: astroglobe-ci-summary
name: astrolabe-ci-summary
steps:
- name: Summary status
run: |
@@ -268,7 +316,7 @@ jobs:
echo "Frontend checks failed"
exit 1
fi
if ${{ needs.changes.outputs.php != 'false' && (needs.php-cs.result != 'success' || needs.psalm.result != 'success') }}; then
if ${{ needs.changes.outputs.php != 'false' && (needs.php-cs.result != 'success' || needs.psalm.result != 'success' || needs.phpunit.result != 'success') }}; then
echo "PHP checks failed"
exit 1
fi
+13 -2
View File
@@ -87,21 +87,32 @@ jobs:
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
MCP_BUMPED=false
if [ "$mcp_commit_count" -gt 0 ]; then
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
echo "Bumping MCP server version..."
./scripts/bump-mcp.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
MCP_BUMPED=true
else
echo "No commits found for MCP server since $last_mcp_tag"
fi
# Bump Helm chart (scope: helm)
# Bump Helm chart (scope: helm OR when MCP appVersion changes)
echo "Checking Helm chart for version bump..."
HELM_HAS_COMMITS=false
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
echo "Bumping Helm chart version..."
HELM_HAS_COMMITS=true
fi
if [ "$HELM_HAS_COMMITS" = true ]; then
echo "Bumping Helm chart version (helm-scoped commits)..."
./scripts/bump-helm.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
elif [ "$MCP_BUMPED" = true ]; then
echo "Bumping Helm chart version (appVersion changed)..."
./scripts/bump-helm.sh --increment PATCH
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
fi
# Bump Astrolabe (scope: astrolabe)
+2 -1
View File
@@ -33,9 +33,10 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
uses: anthropics/claude-code-action@0ed5eeaa54d3b0170e79f1ff29996342cf0605f1 # v1.0.40
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: "renovate-bot-cbcoutinho"
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
+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@0ed5eeaa54d3b0170e79f1ff29996342cf0605f1 # v1.0.40
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:
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Run docker compose with vector sync
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
with:
compose-file: |
./docker-compose.yml
@@ -42,7 +42,7 @@ jobs:
VECTOR_SYNC_SCAN_INTERVAL: "5"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
- name: Wait for Nextcloud to be ready
run: |
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Install uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
+3 -3
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -66,14 +66,14 @@ jobs:
- name: Run docker compose
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
with:
compose-file: "./docker-compose.yml"
#compose-flags: "--profile qdrant"
up-flags: "--build"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
- name: Install Playwright dependencies
run: |
+174
View File
@@ -5,6 +5,180 @@ 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.63.1 (2026-02-03)
### Fix
- **helm**: add backward compatibility for legacy persistence configs
## v0.63.0 (2026-01-28)
### Feat
- **astrolabe**: add background token refresh job
### Fix
- **astrolabe**: add pagination and psalm fixes for token refresh
- **astrolabe**: add locking to prevent token refresh race condition
- **astrolabe**: add issued_at to on-demand token refresh
## v0.62.0 (2026-01-26)
### Feat
- **scripts**: add database query helpers for development
### Fix
- **astrolabe**: resolve Psalm type errors in PDF preview code
- **astrolabe**: fix Psalm baseline and ESLint import order
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
- **astrolabe**: improve error messages for authorization issues
- **astrolabe**: rename OAuthController and fix app password check
- **tests**: improve Astrolabe integration test reliability
- **astrolabe**: update Plotly title attributes for v3 compatibility
- **deps**: update dependency plotly.js-dist-min to v3
### Refactor
- **api**: split management.py into domain-focused modules
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
## v0.61.5 (2026-01-17)
### Fix
- **astrolabe**: improve token refresh error handling and validation
- **astrolabe**: delete stale tokens when refresh fails
- **astrolabe**: resolve CI failures for code quality checks
- **astrolabe**: use internal URL for OAuth token refresh
### Refactor
- **astrolabe**: add PHP property types to fix Psalm errors
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
## v0.61.4 (2026-01-16)
### Fix
- **astrolabe**: Address reviewer feedback for hybrid mode
- **astrolabe**: Fix NcSelect options and CSS loading
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
## v0.61.3 (2026-01-15)
### Fix
- **astrolabe**: address review feedback for Vue 3 bindings
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
## v0.61.2 (2026-01-15)
### Fix
- **ci**: bump helm chart version when MCP appVersion changes
## v0.61.1 (2026-01-15)
### Fix
- **astrolabe**: define appName and appVersion for @nextcloud/vue
## v0.61.0 (2026-01-14)
### Feat
- Add rate limiting and extract helpers for app password endpoints
### Fix
- Add missing annotations for deck remove/unassign operations
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
### Refactor
- Use get_settings() for vector sync enabled check
- Extract storage helper and improve PHP error handling
## v0.60.4 (2026-01-12)
### Fix
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
## 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
+53
View File
@@ -239,6 +239,25 @@ uv run python -m tests.load.benchmark --output results.json --verbose
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
### Quick Query Script (Recommended for Agents)
Use `scripts/dbquery.py` for single SQL statements without requiring approval for each `docker compose exec`:
```bash
# Basic query
./scripts/dbquery.py "SELECT COUNT(*) FROM oc_users"
# Vertical output (one column per line) - useful for wide tables
./scripts/dbquery.py -E "SELECT * FROM oc_oidc_clients LIMIT 1"
# With different credentials
./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES"
```
### Direct Docker Access
For interactive sessions or complex operations:
```bash
# Connect to database
docker compose exec db mariadb -u root -ppassword nextcloud
@@ -264,6 +283,40 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens
- `oc_oidc_redirect_uris` - Redirect URIs
### SQLite Databases (MCP Services)
Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers:
```bash
# List tables
./scripts/sqlitequery.py ".tables"
# Query specific service
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
./scripts/sqlitequery.py -s keycloak "SELECT * FROM oauth_clients"
./scripts/sqlitequery.py -s basic "SELECT * FROM app_passwords"
# With column headers
./scripts/sqlitequery.py --column "SELECT * FROM audit_logs LIMIT 5"
# JSON output
./scripts/sqlitequery.py --json "SELECT * FROM oauth_sessions"
# View schema
./scripts/sqlitequery.py -s oauth ".schema refresh_tokens"
```
**Services**: `mcp` (default), `oauth`, `keycloak`, `basic`
**SQLite Tables**:
- `refresh_tokens` - OAuth refresh tokens with user profiles
- `audit_logs` - Security audit trail
- `oauth_clients` - DCR OAuth client credentials
- `oauth_sessions` - OAuth flow session state
- `registered_webhooks` - Webhook registrations
- `app_passwords` - Multi-user BasicAuth passwords
- `alembic_version` - Migration tracking
## Architecture Quick Reference
**For detailed architecture, see:**
+2 -2
View File
@@ -1,6 +1,6 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
FROM docker.io/library/python:3.12-slim-trixie@sha256:43e4d702bbfe3bd6d5b743dc571b67c19121302eb172951a9b7b0149783a1c21
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+2 -2
View File
@@ -12,12 +12,12 @@
# - Per-session app password authentication
# - Multi-user support via Smithery session config
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
FROM docker.io/library/python:3.12-slim-trixie@sha256:43e4d702bbfe3bd6d5b743dc571b67c19121302eb172951a9b7b0149783a1c21
WORKDIR /app
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+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"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Configure MCP server URL for Astrolabe background sync
# This URL is used by Astrolabe to send app passwords to the MCP server
set -e
# The MCP multi-user BasicAuth service runs on port 8000 inside the container
# From Nextcloud's perspective (inside Docker network), we reach it via service name
MCP_SERVER_URL="${MCP_SERVER_URL:-http://mcp-multi-user-basic:8000}"
echo "Configuring MCP server URL: $MCP_SERVER_URL"
# Set the mcp_server_url in config.php via occ
php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL"
echo "MCP server URL configured successfully"
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.54.0"
version = "0.57.33"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
+236
View File
@@ -14,6 +14,242 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable resource limits
- Grafana dashboard annotations
## nextcloud-mcp-server-0.57.33 (2026-02-06)
## nextcloud-mcp-server-0.57.32 (2026-02-06)
## nextcloud-mcp-server-0.57.31 (2026-02-06)
## nextcloud-mcp-server-0.57.30 (2026-02-06)
## nextcloud-mcp-server-0.57.29 (2026-02-04)
## nextcloud-mcp-server-0.57.28 (2026-02-03)
## nextcloud-mcp-server-0.57.27 (2026-02-03)
### Fix
- **helm**: add backward compatibility for legacy persistence configs
## nextcloud-mcp-server-0.57.26 (2026-01-31)
## nextcloud-mcp-server-0.57.25 (2026-01-31)
## nextcloud-mcp-server-0.57.24 (2026-01-31)
## nextcloud-mcp-server-0.57.23 (2026-01-30)
## nextcloud-mcp-server-0.57.22 (2026-01-30)
## nextcloud-mcp-server-0.57.21 (2026-01-30)
## nextcloud-mcp-server-0.57.20 (2026-01-29)
## nextcloud-mcp-server-0.57.19 (2026-01-28)
## nextcloud-mcp-server-0.57.18 (2026-01-28)
## nextcloud-mcp-server-0.57.17 (2026-01-28)
## nextcloud-mcp-server-0.57.16 (2026-01-28)
### Feat
- **astrolabe**: add background token refresh job
### Fix
- **astrolabe**: add pagination and psalm fixes for token refresh
- **astrolabe**: add locking to prevent token refresh race condition
- **astrolabe**: add issued_at to on-demand token refresh
## nextcloud-mcp-server-0.57.15 (2026-01-26)
### Feat
- **scripts**: add database query helpers for development
### Fix
- **astrolabe**: resolve Psalm type errors in PDF preview code
- **astrolabe**: fix Psalm baseline and ESLint import order
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
- **astrolabe**: improve error messages for authorization issues
- **astrolabe**: rename OAuthController and fix app password check
- **tests**: improve Astrolabe integration test reliability
- **astrolabe**: update Plotly title attributes for v3 compatibility
- **deps**: update dependency plotly.js-dist-min to v3
### Refactor
- **api**: split management.py into domain-focused modules
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
## nextcloud-mcp-server-0.57.14 (2026-01-26)
## nextcloud-mcp-server-0.57.13 (2026-01-24)
## nextcloud-mcp-server-0.57.12 (2026-01-20)
## nextcloud-mcp-server-0.57.11 (2026-01-20)
## nextcloud-mcp-server-0.57.10 (2026-01-19)
## nextcloud-mcp-server-0.57.9 (2026-01-19)
## nextcloud-mcp-server-0.57.8 (2026-01-18)
## nextcloud-mcp-server-0.57.7 (2026-01-17)
### Fix
- **astrolabe**: improve token refresh error handling and validation
- **astrolabe**: delete stale tokens when refresh fails
- **astrolabe**: resolve CI failures for code quality checks
- **astrolabe**: use internal URL for OAuth token refresh
### Refactor
- **astrolabe**: add PHP property types to fix Psalm errors
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
## nextcloud-mcp-server-0.57.6 (2026-01-16)
## nextcloud-mcp-server-0.57.5 (2026-01-16)
## nextcloud-mcp-server-0.57.4 (2026-01-16)
### Fix
- **astrolabe**: Address reviewer feedback for hybrid mode
- **astrolabe**: Fix NcSelect options and CSS loading
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
## nextcloud-mcp-server-0.57.3 (2026-01-15)
## nextcloud-mcp-server-0.57.2 (2026-01-15)
### Fix
- **astrolabe**: address review feedback for Vue 3 bindings
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
## nextcloud-mcp-server-0.57.1 (2026-01-15)
### Fix
- **ci**: bump helm chart version when MCP appVersion changes
- **astrolabe**: define appName and appVersion for @nextcloud/vue
## nextcloud-mcp-server-0.57.0 (2026-01-15)
### Feat
- Add rate limiting and extract helpers for app password endpoints
### Fix
- Add missing annotations for deck remove/unassign operations
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
- **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
### Refactor
- Use get_settings() for vector sync enabled check
- Extract storage helper and improve PHP error handling
## 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
+3 -3
View File
@@ -4,6 +4,6 @@ dependencies:
version: 1.16.3
- name: ollama
repository: https://otwld.github.io/ollama-helm
version: 1.36.0
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
generated: "2025-12-22T11:09:39.166328543Z"
version: 1.40.0
digest: sha256:d8cbf3eab778b3e28818dd1f9cbd71c99ce968fb2a46880b162f988a59a5fedf
generated: "2026-01-30T11:10:10.104463708Z"
+3 -3
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.57.33
appVersion: "0.63.1"
keywords:
- nextcloud
- mcp
@@ -31,6 +31,6 @@ dependencies:
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
version: "1.36.0"
version: "1.40.0"
repository: https://otwld.github.io/ollama-helm
condition: ollama.enabled
+35 -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
@@ -118,6 +118,25 @@ ingress:
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
#### Data Storage
The `/app/data` directory is used for application data (token databases, Qdrant persistent storage, etc.). It is always mounted as writable to support the read-only root filesystem security context.
| Parameter | Description | Default |
|-----------|-------------|---------|
| `dataStorage.enabled` | Enable persistent storage for `/app/data` | `false` |
| `dataStorage.size` | Size of data storage PVC | `1Gi` |
| `dataStorage.storageClass` | Storage class (leave empty for default) | `""` |
| `dataStorage.accessMode` | Access mode | `ReadWriteOnce` |
| `dataStorage.existingClaim` | Use existing PVC | `""` |
**When to enable persistence:**
- Multi-user basic auth with offline access (stores `tokens.db`)
- Qdrant persistent mode (stores vector database)
- Any feature requiring persistent app data
**When persistence is disabled:** Uses `emptyDir` (non-persistent, data lost on pod restart, but directory remains writable).
#### MCP Server Configuration
| Parameter | Description | Default |
@@ -208,16 +227,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 +446,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 +478,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 +556,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 +595,7 @@ ollama:
Or use an external Ollama instance:
```yaml
vectorSync:
semanticSearch:
enabled: true
qdrant:
@@ -592,7 +611,7 @@ ollama:
Or use OpenAI for embeddings:
```yaml
vectorSync:
semanticSearch:
enabled: true
qdrant:
@@ -689,7 +708,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 }}
@@ -120,6 +120,55 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
{{- end }}
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
{{- if or $legacyMultiUserBasic $legacyQdrant }}
================================================================================
DEPRECATION WARNING
================================================================================
You are using deprecated persistence configuration that will be removed in a
future release. Your deployment will continue to work, but please migrate to
the new unified dataStorage configuration.
Deprecated settings detected:
{{- if $legacyMultiUserBasic }}
- auth.multiUserBasic.persistence.* (currently enabled)
{{- end }}
{{- if $legacyQdrant }}
- qdrant.localPersistence.* (currently enabled)
{{- end }}
To migrate, update your values.yaml:
dataStorage:
enabled: true
{{- if $legacyMultiUserBasic }}
size: {{ .Values.auth.multiUserBasic.persistence.size }}
{{- else if $legacyQdrant }}
size: {{ .Values.qdrant.localPersistence.size }}
{{- end }}
# storageClass: "" # Optional: specify storage class
# existingClaim: "" # Optional: use existing PVC to preserve data
After migrating, remove the deprecated settings:
{{- if $legacyMultiUserBasic }}
- auth.multiUserBasic.persistence.enabled
- auth.multiUserBasic.persistence.size
- auth.multiUserBasic.persistence.storageClass
- auth.multiUserBasic.persistence.accessMode
{{- end }}
{{- if $legacyQdrant }}
- qdrant.localPersistence.enabled
- qdrant.localPersistence.size
- qdrant.localPersistence.storageClass
- qdrant.localPersistence.accessMode
{{- end }}
================================================================================
{{- end }}
For more information and documentation:
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
@@ -127,6 +127,55 @@ Create the name of the PVC to use for Qdrant local persistent storage
{{- end }}
{{- end }}
{{/*
Create the name of the PVC to use for /app/data storage
*/}}
{{- define "nextcloud-mcp-server.dataStoragePvcName" -}}
{{- if .Values.dataStorage.existingClaim }}
{{- .Values.dataStorage.existingClaim }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-data-storage
{{- end }}
{{- end }}
{{/*
Determine if data storage PVC should be enabled (backward compatible)
Checks new dataStorage.enabled OR legacy persistence configs
*/}}
{{- define "nextcloud-mcp-server.dataStorageEnabled" -}}
{{- if .Values.dataStorage.enabled -}}
true
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
true
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
true
{{- else -}}
false
{{- end -}}
{{- end }}
{{/*
Check if legacy multi-user-basic persistence config is being used
*/}}
{{- define "nextcloud-mcp-server.legacyMultiUserBasicPersistence" -}}
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.dataStorage.enabled) -}}
true
{{- else -}}
false
{{- end -}}
{{- end }}
{{/*
Check if legacy qdrant persistence config is being used
*/}}
{{- define "nextcloud-mcp-server.legacyQdrantPersistence" -}}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.dataStorage.enabled) -}}
true
{{- else -}}
false
{{- end -}}
{{- end }}
{{/*
Return the MCP server port
*/}}
@@ -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
@@ -286,14 +286,8 @@ spec:
- name: oauth-storage
mountPath: /app/.oauth
{{- end }}
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
- name: token-storage
- name: data-storage
mountPath: /app/data
{{- end }}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
- name: qdrant-data
mountPath: /app/data
{{- end }}
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -305,15 +299,12 @@ spec:
persistentVolumeClaim:
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
{{- end }}
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
- name: token-storage
- name: data-storage
{{- if eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true" }}
persistentVolumeClaim:
claimName: {{ include "nextcloud-mcp-server.multiUserBasicPvcName" . }}
{{- end }}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
- name: qdrant-data
persistentVolumeClaim:
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
claimName: {{ include "nextcloud-mcp-server.dataStoragePvcName" . }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.volumes }}
{{- toYaml . | nindent 8 }}
+20 -24
View File
@@ -16,38 +16,34 @@ spec:
storage: {{ .Values.auth.oauth.persistence.size }}
{{- end }}
---
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.auth.multiUserBasic.persistence.existingClaim) }}
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
{{- $accessMode := .Values.dataStorage.accessMode }}
{{- $storageClass := .Values.dataStorage.storageClass }}
{{- $size := .Values.dataStorage.size }}
{{- if $legacyMultiUserBasic }}
{{- $accessMode = .Values.auth.multiUserBasic.persistence.accessMode }}
{{- $storageClass = .Values.auth.multiUserBasic.persistence.storageClass }}
{{- $size = .Values.auth.multiUserBasic.persistence.size }}
{{- else if $legacyQdrant }}
{{- $accessMode = .Values.qdrant.localPersistence.accessMode }}
{{- $storageClass = .Values.qdrant.localPersistence.storageClass }}
{{- $size = .Values.qdrant.localPersistence.size }}
{{- end }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-token-storage
name: {{ include "nextcloud-mcp-server.fullname" . }}-data-storage
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.auth.multiUserBasic.persistence.accessMode }}
{{- if .Values.auth.multiUserBasic.persistence.storageClass }}
storageClassName: {{ .Values.auth.multiUserBasic.persistence.storageClass }}
- {{ $accessMode }}
{{- if $storageClass }}
storageClassName: {{ $storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.auth.multiUserBasic.persistence.size }}
{{- end }}
---
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.qdrant.localPersistence.accessMode }}
{{- if .Values.qdrant.localPersistence.storageClass }}
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.qdrant.localPersistence.size }}
storage: {{ $size }}
{{- end }}
+37 -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: ""
@@ -132,6 +139,27 @@ auth:
# Use existing PVC
existingClaim: ""
# Data Storage Configuration
# Persistent volume for /app/data directory
# Used for: token databases, qdrant persistent storage, and any app data
# When disabled, uses emptyDir (non-persistent, but still writable)
dataStorage:
# Enable persistent storage for /app/data
# Set to true when using:
# - Multi-user basic auth with offline access (stores tokens.db)
# - Qdrant persistent mode (stores vector database)
# - Any feature requiring persistent app data
# Set to false for basic auth without persistence (uses emptyDir)
enabled: false
# Storage class (leave empty for default)
storageClass: ""
accessMode: ReadWriteOnce
# Size for data storage (should accommodate tokens.db and/or qdrant data)
# Recommended: 1Gi minimum, 5Gi for production with qdrant
size: 1Gi
# Use existing PVC
existingClaim: ""
# MCP server configuration
mcp:
# Transport mode (default: streamable-http for SSE)
@@ -358,10 +386,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 +401,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
+18 -17
View File
@@ -3,11 +3,13 @@ services:
# https://hub.docker.com/_/mariadb
db:
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
image: docker.io/library/mariadb:lts@sha256:1cac8492bd78b1ec693238dc600be173397efd7b55eabc725abc281dc855b482
image: docker.io/library/mariadb:lts@sha256:345fa26d595e8c7fe298e0c4098ed400356f502458769c8902229b3437d6da2b
restart: always
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
@@ -17,14 +19,14 @@ services:
# Note: Redis is an external service. You can find more information about the configuration here:
# https://hub.docker.com/_/redis
redis:
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
image: docker.io/library/redis:alpine@sha256:0804c395e634e624243387d3c3a9c45fcaca876d313c2c8b52c3fdf9a912dded
restart: always
app:
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
image: docker.io/library/nextcloud:32.0.5@sha256:4b66e9bd8cb2c8af5457c1e2606c9937af2fcccbe4f6338956bc5990caec8968
restart: always
ports:
- 0.0.0.0:8080:80
- 127.0.0.1:8080:80
depends_on:
- redis
- db
@@ -52,14 +54,14 @@ services:
retries: 30
recipes:
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
image: docker.io/library/nginx:alpine@sha256:4870c12cd2ca986de501a804b4f506ad3875a0b1874940ba0a2c7f763f1855b2
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
unstructured:
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:9945a842ba983afcf110053cbcc0df7e4bd09ba9f02aa213824ce3f986713635
restart: always
ports:
- 127.0.0.1:8002:8000
@@ -86,8 +88,8 @@ services:
- NEXTCLOUD_PASSWORD=admin
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Vector sync configuration (ADR-007)
#- VECTOR_SYNC_ENABLED=true
# Semantic search configuration (ADR-007, ADR-021)
#- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -138,14 +140,13 @@ 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_BACKGROUND_OPERATIONS=true
# Token storage (required for middleware initialization)
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
- VECTOR_SYNC_ENABLED=true
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -178,7 +179,7 @@ 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_BACKGROUND_OPERATIONS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
@@ -186,8 +187,8 @@ services:
# Tokens must contain BOTH MCP and Nextcloud audiences
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
# Vector sync configuration (ADR-007)
- VECTOR_SYNC_ENABLED=true
# Semantic search configuration (ADR-007, ADR-021)
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -207,7 +208,7 @@ services:
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
command:
- "start-dev"
- "--import-realm"
@@ -255,7 +256,7 @@ services:
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
# Refresh token storage (ADR-002 Tier 1 & 2)
- ENABLE_OFFLINE_ACCESS=true
- ENABLE_BACKGROUND_OPERATIONS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
@@ -288,13 +289,13 @@ services:
- 127.0.0.1:8081:8081
environment:
- SMITHERY_DEPLOYMENT=true
- VECTOR_SYNC_ENABLED=false
- ENABLE_SEMANTIC_SEARCH=false
- PORT=8081
profiles:
- smithery
qdrant:
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
restart: always
ports:
- 127.0.0.1:6333:6333 # REST API
+461
View File
@@ -0,0 +1,461 @@
# Authentication Flows by Deployment Mode
This document provides a unified reference for authentication flows across all deployment modes. For configuration details, see [Authentication](authentication.md). For OAuth protocol details, see [OAuth Architecture](oauth-architecture.md).
## Quick Reference Matrix
| Mode | Client → MCP → NC | Background Sync | Astrolabe → MCP |
|------|-------------------|-----------------|-----------------|
| [Single-User BasicAuth](#1-single-user-basicauth) | Embedded credentials | Same credentials | N/A |
| [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token |
| [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token |
| [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token |
| [Smithery Stateless](#5-smithery-stateless) | Session parameters | Not supported | N/A |
## Communication Patterns
This document covers three distinct communication patterns:
1. **MCP Client → MCP Server → Nextcloud**: Interactive tool calls initiated by users through MCP clients (Claude Desktop, etc.)
2. **MCP Server → Nextcloud**: Background operations like vector sync that run without user interaction
3. **Astrolabe → MCP Server**: Nextcloud app backend communication for settings UI and unified search
---
## Deployment Modes
### 1. Single-User BasicAuth
**Use Case:** Personal Nextcloud instance, local development, single-user deployments.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── MCP Request ─────────────▶│ │
│ (no auth required) │ │
│ │── HTTP + BasicAuth ───────▶│
│ │ Authorization: Basic │
│ │ (embedded credentials) │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Credentials embedded in server configuration (`NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`)
- Single shared `NextcloudClient` created at startup
- No MCP-level authentication required (server trusts local clients)
- All requests use the same Nextcloud user
**Implementation:** `context.py:78-79` - Returns shared client from lifespan context
#### Background Sync
Uses the same embedded credentials as interactive requests. The background job accesses Nextcloud with the configured username/password.
**Implementation:** Background jobs use `get_settings()` to access credentials
#### Astrolabe Integration
Not applicable - Astrolabe is only used in multi-user deployments where users need personal settings and token management.
---
### 2. Multi-User BasicAuth
**Use Case:** Internal deployment where users provide their own credentials via HTTP headers.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── MCP Request ─────────────▶│ │
│ Authorization: Basic │ │
│ (user credentials) │ │
│ │── BasicAuthMiddleware ────▶│
│ │ Extracts credentials │
│ │ │
│ │── HTTP + BasicAuth ───────▶│
│ │ (pass-through) │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- `BasicAuthMiddleware` extracts credentials from `Authorization: Basic` header
- Credentials passed through to Nextcloud (not stored)
- Client created per-request from extracted credentials
- Stateless - no credential storage between requests
**Implementation:** `context.py:187-248` - `_get_client_from_basic_auth()` extracts credentials from request state
#### Background Sync (Optional)
Requires `ENABLE_OFFLINE_ACCESS=true`. Users can store app passwords via Astrolabe for background operations.
```
Astrolabe MCP Server Nextcloud
│ │ │
│── Store App Password ──────▶│ │
│ (via management API) │ │
│ │── Store in SQLite ────────▶│
│ │ (encrypted) │
│◀── Confirmation ────────────│ │
│ │ │
│ [Background Job] │ │
│ │── Retrieve app password ──▶│
│ │ (from encrypted storage) │
│ │── HTTP + BasicAuth ───────▶│
│ │ (stored app password) │
│ │◀── API Response ───────────│
```
**Requirements:**
- `ENABLE_OFFLINE_ACCESS=true`
- `TOKEN_ENCRYPTION_KEY` for credential encryption
- `TOKEN_STORAGE_DB` for SQLite storage path
#### Astrolabe → MCP Server
```
Astrolabe MCP Server Nextcloud OIDC
│ │ │
│── OAuth Flow ──────────────▶│◀── Token from IdP ────────▶│
│ (user initiates) │ │
│ │ │
│── Bearer Token ────────────▶│ │
│ (management API calls) │ │
│ │── Validate via JWKS ──────▶│
│ │ (or introspection) │
│◀── API Response ────────────│ │
```
**Key characteristics:**
- Astrolabe has its own OAuth client (`astrolabe_client_id` in Nextcloud config)
- Tokens are validated by MCP server using Nextcloud OIDC JWKS
- Authorization check: `token.sub == requested_resource_owner`
- Any valid Nextcloud OIDC token accepted (relaxed audience validation per ADR-018)
**Implementation:** `unified_verifier.py:120-183` - `verify_token_for_management_api()` validates without strict audience check
---
### 3. OAuth Single-Audience (Default)
**Use Case:** Multi-user deployment with OAuth authentication. Tokens work for both MCP and Nextcloud.
This is the default mode when `NEXTCLOUD_USERNAME`/`NEXTCLOUD_PASSWORD` are not set.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── Bearer Token ────────────▶│ │
│ aud: ["mcp-server", │ │
│ "nextcloud"] │ │
│ │── Validate MCP audience ──▶│
│ │ (UnifiedTokenVerifier) │
│ │ │
│ │── HTTP + Same Token ──────▶│
│ │ Authorization: Bearer │
│ │ (multi-audience token) │
│ │ │
│ │ NC validates its own aud │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Token contains both audiences: `aud: ["mcp-server", "nextcloud"]`
- MCP server validates only MCP audience (per RFC 7519)
- Nextcloud independently validates its own audience
- No token exchange needed - same token used throughout
- Stateless operation for interactive requests
**Token validation flow:**
1. `UnifiedTokenVerifier.verify_token()` validates MCP audience
2. Token passed directly to Nextcloud via `get_client_from_context()`
3. Nextcloud validates its own audience when receiving API calls
**Implementation:**
- `unified_verifier.py:185-252` - `_verify_mcp_audience()` validates MCP audience only
- `context.py:96-99` - Uses token directly in multi-audience mode
#### Background Sync
Requires `ENABLE_OFFLINE_ACCESS=true`. Uses stored refresh tokens to obtain access tokens for background operations.
```
MCP Server Nextcloud OIDC
│ │
[Background Job starts] │ │
│── Get refresh token ──────▶│
│ (from encrypted storage) │
│ │
│── Token refresh request ──▶│
│ grant_type=refresh_token │
│ scope=openid profile ... │
│◀── New access + refresh ───│
│ (rotation) │
│ │
│── Store rotated refresh ──▶│
│ (encrypted) │
│ │
│── HTTP + Access Token ────▶│
│ Authorization: Bearer │
│◀── API Response ───────────│
```
**Key characteristics:**
- Refresh tokens stored encrypted in SQLite (`TOKEN_STORAGE_DB`)
- Nextcloud OIDC rotates refresh tokens on every use (one-time use)
- `TokenBrokerService` handles token lifecycle
- Per-user locking prevents race conditions during concurrent refresh
**Implementation:**
- `token_broker.py:269-362` - `get_background_token()` handles refresh with locking
- `token_broker.py:428-509` - `_refresh_access_token_with_scopes()` exchanges refresh token
#### Astrolabe → MCP Server
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
---
### 4. OAuth Token Exchange (RFC 8693)
**Use Case:** Multi-user deployment where MCP tokens are separate from Nextcloud tokens. Provides stronger security boundaries.
Enabled by `ENABLE_TOKEN_EXCHANGE=true`.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud OIDC
│ │ │
│── Bearer Token ────────────▶│ │
│ aud: "mcp-server" │ │
│ (MCP audience only) │ │
│ │── Validate MCP audience ──▶│
│ │ │
│ │── RFC 8693 Exchange ──────▶│
│ │ grant_type= │
│ │ urn:ietf:params:oauth: │
│ │ grant-type:token-exchange
│ │ subject_token=<mcp-token>│
│ │ requested_audience= │
│ │ "nextcloud" │
│ │◀── Delegated Token ────────│
│ │ aud: "nextcloud" │
│ │ │
│ │── HTTP + Delegated Token ─▶│
│ │ Authorization: Bearer │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Strict audience separation: MCP token has `aud: "mcp-server"` only
- Server exchanges for Nextcloud-audience token on each request
- Ephemeral delegated tokens (not cached by default)
- Strongest security boundary between MCP and Nextcloud access
**Token exchange details:**
- Uses RFC 8693 "urn:ietf:params:oauth:grant-type:token-exchange"
- Subject token: MCP access token
- Requested audience: Nextcloud resource URI
- Result: Short-lived token scoped for Nextcloud
**Implementation:**
- `token_broker.py:220-267` - `get_session_token()` performs on-demand exchange
- `token_exchange.py` - `exchange_token_for_delegation()` implements RFC 8693
- `context.py:88-94` - Routes to session client in exchange mode
#### Background Sync
Same as OAuth Single-Audience. Uses stored refresh tokens from Flow 2 provisioning.
```
MCP Server Nextcloud OIDC
│ │
[User provisions access] │ │
│── Flow 2 OAuth ───────────▶│
│ client_id="mcp-server" │
│ scope=offline_access ... │
│◀── Refresh Token ──────────│
│ (stored encrypted) │
│ │
[Background Job runs later] │ │
│── Refresh for background ─▶│
│ (same as single-audience)│
```
**Key difference from interactive:**
- Interactive: On-demand token exchange per request
- Background: Uses pre-provisioned refresh tokens (Flow 2)
#### Astrolabe → MCP Server
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
---
### 5. Smithery Stateless
**Use Case:** Multi-tenant SaaS deployment via Smithery platform. Fully stateless.
Enabled by `SMITHERY_DEPLOYMENT=true`.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── SSE Connect ─────────────▶│ │
│ ?nextcloud_url=... │ │
│ &username=... │ │
│ &app_password=... │ │
│ │── SmitheryConfigMiddleware │
│ │ Extract URL params │
│ │ │
│── MCP Request ─────────────▶│ │
│ (no Authorization header) │ │
│ │── Create per-request ─────▶│
│ │ NextcloudClient │
│ │ │
│ │── HTTP + BasicAuth ───────▶│
│ │ (from session params) │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Configuration passed via URL query parameters (Smithery `configSchema`)
- No persistent state - client created fresh per request
- No OAuth infrastructure
- No background sync support (stateless)
- No admin UI available
**Required session parameters:**
- `nextcloud_url`: Nextcloud instance URL
- `username`: Nextcloud username
- `app_password`: Nextcloud app password
**Implementation:** `context.py:108-184` - `_get_client_from_session_config()` creates client from session params
#### Background Sync
Not supported. Smithery mode is fully stateless with no credential storage.
#### Astrolabe Integration
Not applicable. Smithery deployments don't integrate with Astrolabe.
---
## Astrolabe Background Token Refresh
The Astrolabe Nextcloud app includes a background job that proactively refreshes OAuth tokens before expiration.
```
Nextcloud Cron Astrolabe MCP Server IdP
│ │ │
│── Run RefreshUserTokens ───▶│ │
│ (every 15 minutes) │ │
│ │── Get all user tokens ────▶│
│ │ (from preferences) │
│ │ │
│ [For each user] │ │
│ │── Check expiry ───────────▶│
│ │ refresh if <50% lifetime │
│ │ │
│ │── Acquire user lock ──────▶│
│ │ (prevent race condition) │
│ │ │
│ │── Token refresh request ──▶│
│ │ grant_type=refresh_token │
│ │◀── New tokens ─────────────│
│ │ │
│ │── Store new tokens ───────▶│
│ │ (with issued_at) │
│◀── Job complete ────────────│ │
```
**Key characteristics:**
- Runs every 15 minutes via Nextcloud cron
- Refreshes when <50% of token lifetime remains
- Uses locking to prevent race conditions with on-demand refresh
- Stores `issued_at` timestamp for accurate lifetime calculation
- Batch processing (100 users at a time) for memory efficiency
**Implementation:** `third_party/astrolabe/lib/BackgroundJob/RefreshUserTokens.php`
---
## Configuration Quick Reference
### Single-User BasicAuth
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
```
### Multi-User BasicAuth
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
# Optional: For background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<32-byte-key>
TOKEN_STORAGE_DB=/data/tokens.db
```
### OAuth Single-Audience (Default)
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
# No username/password triggers OAuth mode
# Optional: Static client credentials (instead of DCR)
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
# Optional: For background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<32-byte-key>
TOKEN_STORAGE_DB=/data/tokens.db
```
### OAuth Token Exchange
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
# Optional: For background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<32-byte-key>
TOKEN_STORAGE_DB=/data/tokens.db
```
### Smithery Stateless
```bash
SMITHERY_DEPLOYMENT=true
# All other config comes from session URL parameters
```
---
## Related Documentation
- [Authentication](authentication.md) - Configuration details and setup guides
- [OAuth Architecture](oauth-architecture.md) - Deep OAuth protocol details
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md) - Dual OAuth flow architecture
- [ADR-005: Token Audience Validation](ADR-005-token-audience-validation.md) - Audience validation strategy
- [ADR-018: Nextcloud PHP App](ADR-018-nextcloud-php-app-for-settings-ui.md) - Astrolabe integration
- [ADR-020: Deployment Modes](ADR-020-deployment-modes-and-configuration-validation.md) - Mode detection and validation
+136
View File
@@ -140,6 +140,142 @@ 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 |
### Astrolabe User Setup (Hybrid Mode)
When Astrolabe connects to an MCP server running in hybrid mode, users must complete a **two-step credential setup**:
#### Step 1: OAuth Authorization (Search Access)
**Purpose**: Allows Astrolabe to call MCP server APIs on the user's behalf.
**Flow**:
1. User opens Astrolabe Personal Settings in Nextcloud
2. Clicks "Authorize" button
3. Redirected to Astrolabe's OAuth controller (`/apps/astrolabe/oauth/initiate`)
4. OAuth controller discovers IdP from MCP server's `/api/v1/status` endpoint
5. User authenticates with Identity Provider (Nextcloud OIDC or external IdP)
6. Tokens stored in Nextcloud user config (`McpTokenStorage`)
7. Astrolabe can now perform semantic searches via MCP API
**Technical Details**:
- Token audience: MCP server
- Token storage: Nextcloud app config (`oc_preferences`)
- Used for: `/api/v1/search`, `/api/v1/status` (authenticated endpoints)
#### Step 2: App Password (Background Indexing)
**Purpose**: Allows MCP server to access Nextcloud content for background sync.
**Flow**:
1. User generates app password in Nextcloud Security settings
2. Enters app password in Astrolabe Personal Settings
3. App password validated against Nextcloud and stored (encrypted)
4. MCP server can now index user's content in the background
**Technical Details**:
- Credential type: Nextcloud app password
- Token storage: MCP server's refresh token database
- Used for: Background indexing, content sync to vector database
#### Why Two Credentials?
| Direction | Auth Method | Purpose |
|-----------|-------------|---------|
| Astrolabe → MCP Server | OAuth Bearer Token | User searches, settings management |
| MCP Server → Nextcloud | BasicAuth (App Password) | Background content indexing |
The separation ensures:
- **Security**: Each credential has limited scope
- **Audit Trail**: OAuth tokens identify users; app passwords enable background ops
- **User Control**: Users explicitly grant each type of access
### 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:
+206
View File
@@ -0,0 +1,206 @@
# Introducing Astrolabe: Navigate Your Data Universe in Nextcloud
Your Nextcloud instance holds years of notes, projects, recipes, contacts, and documents. But when you need to find something, you're stuck typing exact keywords and hoping for the best. Search "car repair" and miss that note titled "Vehicle maintenance tips." Search "meeting agenda" and overlook the calendar event called "Team sync." Traditional keyword search demands that you remember exactly how you wrote things down.
What if your search could understand what you *mean*, not just what you type?
Meet **Astrolabe**—a Nextcloud app that brings AI-powered semantic search to your self-hosted cloud. Named after the ancient navigational instrument that helped travelers chart courses by the stars, Astrolabe helps you navigate your personal knowledge by mapping the semantic connections between your documents.
## The Astrolabe Metaphor
The astrolabe was one of humanity's most elegant scientific instruments—an analog computer for solving problems related to time and the position of celestial bodies. Its theoretical foundation traces back to **Hipparchus of Nicaea** (c. 190120 BCE), who discovered the stereographic projection that allows a three-dimensional celestial sphere to be represented on a flat surface. Later Greek scholars like **Theon of Alexandria** and his daughter **Hypatia** refined it into a practical instrument, and during the Islamic Golden Age, astronomers in Baghdad, Damascus, and Cordoba perfected its design and applications.
For nearly two millennia, astrolabes served astronomers, navigators, scholars, and religious officials across the Greek, Byzantine, Islamic, and medieval European worlds. These instruments allowed users to determine time, find celestial positions, calculate daylight hours, identify constellations, and even determine the direction of Mecca for prayer—all without complex calculations. The astrolabe made the vast complexity of the heavens understandable and navigable.
**Astrolabe** (the app) does the same for your data. Every document, note, and calendar event becomes a point of light in your personal data universe. The app maps their semantic relationships—their meaning, not just their words—and suddenly the connections become visible. Documents cluster by topic, related ideas sit nearby, and you can navigate this landscape as naturally as medieval scholars once read the stars. Where the original astrolabe projected the celestial sphere onto brass, this one projects your knowledge into explorable semantic space.
## Semantic Search: Find Meaning, Not Just Keywords
The core feature of Astrolabe is semantic search. Instead of matching exact keywords, it understands the concepts in your query and finds related content.
**What this looks like in practice:**
| You Search For | Traditional Search Finds | Astrolabe Also Finds |
|----------------|--------------------------|----------------------|
| "car repair" | Documents containing "car repair" | Notes about "vehicle maintenance," "fixing the truck" |
| "team planning" | Documents with "team planning" | Calendar events titled "Q2 kickoff," Deck cards about "project roadmap" |
| "pasta recipes" | Documents with "pasta recipes" | Notes about "Italian cooking," "homemade noodles," "carbonara tips" |
This works across multiple Nextcloud apps: Notes, Files (including PDFs with OCR), Deck cards, Calendar events, Contacts, and News/RSS items. One search bar, all your content, understood by meaning.
### Hybrid Search: Best of Both Worlds
Sometimes you want exact matches ("PROJ-2024-001"), sometimes you want semantic understanding ("that project from last year about authentication"). Astrolabe's hybrid search combines both approaches:
- **Semantic search** uses embeddings to find conceptually related content
- **BM25 keyword search** finds exact matches and important terms
- **Reciprocal Rank Fusion (RRF)** intelligently merges the results
You can adjust the balance or switch modes entirely depending on your needs.
![Unified Search Integration](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/01-unified-search-astrolabe.png?raw=1)
*Astrolabe results appear alongside traditional search in Nextcloud's unified search bar*
## Visualize Your Data Universe
Beyond search, Astrolabe includes an interactive 3D visualization that shows your documents positioned in semantic space. Similar documents cluster together. Topics form constellations. You can rotate, zoom, and explore.
This isn't just eye candy—it's a practical tool for knowledge discovery:
- **Find forgotten connections**: Search for your current project and watch as related documents from months ago light up nearby
- **Spot topic clusters**: See how your notes naturally group by subject
- **Explore the unknown**: Click on points near your search results to discover content you didn't know was related
The visualization uses Principal Component Analysis (PCA) to project high-dimensional embeddings (768 dimensions) down to 3D space while preserving the relationships between documents. We implemented a lightweight, custom PCA specifically for this—no heavyweight ML libraries required.
![3D Vector Visualization](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/02-semantic-search-with-plot.png?raw=1)
*Documents cluster by semantic similarity. The query point (red) shows your search, and related documents cluster nearby*
## Power Your AI Agents
Astrolabe isn't just for humans—it's for your AI assistants too.
The backend runs a **Model Context Protocol (MCP)** server, which means AI tools like Claude Desktop, Cursor, or custom agents can connect directly to your Nextcloud data. Your AI assistant can:
- Search your notes semantically ("Find everything related to the Kubernetes migration")
- Retrieve document content for context
- Get AI-generated answers with citations from your documents (RAG)
The critical point: **your data never leaves your infrastructure**. The MCP server runs on your hardware. Your AI assistant sends queries, the server returns results, and you maintain full control. No documents uploaded to third-party services.
### Retrieval-Augmented Generation (RAG)
Ask a question, and Astrolabe can retrieve relevant documents and have your AI synthesize an answer—complete with citations:
```
You: "What were the main issues we had deploying to production last month?"
Astrolabe finds: 3 relevant notes, 2 Deck cards, 1 calendar event
AI generates: "Based on your documents, there were three main issues:
1. Database migration timeout (see Note: 'Prod deploy 2024-01-15')
2. SSL certificate renewal (see Deck card: 'Ops Tasks')
3. Resource limits on the new pods (see Note: 'K8s troubleshooting')
```
This uses MCP's sampling capability—the server doesn't run its own LLM. Instead, it asks your client's AI to generate the response. You choose the model, you control the costs.
## Under the Hood
For the technically curious, here's how Astrolabe works:
### Embedding Providers
Astrolabe supports multiple backends for generating semantic embeddings:
- **Amazon Bedrock**: Enterprise-grade, Titan embeddings
- **OpenAI**: Direct OpenAI API or compatible endpoints (including GitHub Models)
- **Ollama**: Self-hosted, privacy-focused, runs entirely on your hardware
The system auto-detects available providers based on environment variables and falls back gracefully. Deploy Ollama on your server for full privacy, or use Bedrock for enterprise scale—same codebase, zero code changes.
### Background Indexing
Documents are indexed automatically via webhooks. When you create or edit a note, Nextcloud fires an event, and the MCP server processes it in the background. No manual sync required.
The indexing pipeline:
1. **Scanner** detects changes via ETags and modification timestamps
2. **Queue** manages backpressure (up to 10k pending documents)
3. **Worker pool** processes embeddings concurrently (configurable, default 3 workers)
4. **Qdrant** stores vectors for fast similarity search
### Lightweight by Design
We deliberately avoided heavyweight dependencies:
- **Custom PCA**: No scikit-learn, just efficient eigendecomposition
- **In-process async**: No separate message queues or worker processes—just anyio TaskGroups
- **Plugin architecture**: New apps (Notes, Calendar, etc.) are simple scanner/processor implementations
This means Astrolabe runs comfortably alongside your Nextcloud on modest hardware.
```
┌──────────────┐ ┌─────────────┐ ┌─────────┐
│ Nextcloud │────▶│ MCP Server │────▶│ Qdrant │
│ (Astrolabe) │◀────│ (Python) │◀────│ (Vectors)│
└──────────────┘ └─────────────┘ └─────────┘
│ │
│ OAuth/Token │ Embeddings
▼ ▼
┌────────┐ ┌──────────┐
│ User │ │ Ollama/ │
│Browser │ │ Bedrock │
└────────┘ └──────────┘
```
## Getting Started
### Requirements
- Nextcloud 31 or 32
- MCP server instance (Docker recommended)
- Vector database (Qdrant, included in Docker setup)
- Embedding provider (Ollama for self-hosted, or cloud options)
### Quick Setup
1. **Install the Astrolabe app** from the Nextcloud App Store (or manually)
2. **Start the MCP server** (Docker Compose makes this easy):
```bash
docker compose up -d mcp qdrant ollama
```
3. **Configure the connection** in your Nextcloud `config.php`:
```php
'astrolabe' => [
'mcp_server_url' => 'http://localhost:8000',
],
```
4. **Authorize access** in Settings → Personal → Astrolabe
5. **Start searching** using Nextcloud's unified search bar
For detailed setup instructions, including OAuth configuration and embedding provider options, see the [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server).
## What Can You Index?
Astrolabe currently supports:
| App | What Gets Indexed |
|-----|-------------------|
| **Notes** | Full text and metadata |
| **Files** | PDFs (with OCR), DOCX, text files |
| **Deck** | Card titles and descriptions |
| **Calendar** | Event titles, descriptions, and details |
| **Contacts** | Names, notes, and contact information |
| **News** | RSS/Atom feed articles |
Each result shows the document type, relevance score, and a direct link to the source. For large documents, it shows which chunk (section) matched.
![Chunk Viewer](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/03-chunk-viewer-open.png?raw=1)
*Click a result to see the matching chunk in context*
## Who Is This For?
**Researchers and students**: Find all notes related to your thesis topic, even when you used different terminology across semesters. Discover connections between papers you read months apart.
**Teams and organizations**: Surface institutional knowledge that would otherwise stay buried. New team members can search for concepts instead of knowing exactly what to look for.
**Developers**: Connect your AI coding assistant to your Nextcloud. Give it access to project notes, meeting records, and documentation without copy-pasting context.
**Personal knowledge managers**: Discover forgotten documents related to your current work. Watch your knowledge base evolve over time through the visualization.
## Try It Out
Astrolabe is open source (AGPL) and ready to use. Your data universe has been waiting in the dark—it's time to turn on the lights.
- **Install**: [Nextcloud App Store](https://apps.nextcloud.com/apps/astrolabe)
- **Source**: [GitHub](https://github.com/cbcoutinho/nextcloud-mcp-server)
- **Documentation**: [Setup Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/tree/master/docs)
- **Issues**: [Report bugs or request features](https://github.com/cbcoutinho/nextcloud-mcp-server/issues)
---
*Astrolabe is maintained by [Chris Coutinho](https://github.com/cbcoutinho). Contributions welcome.*
+22
View File
@@ -531,6 +531,28 @@ docker-compose up
---
## Astrolabe Internal URL
The Astrolabe Nextcloud app may need to make internal HTTP requests to the local web server (e.g., for OAuth token refresh). By default, it uses `http://localhost` which works for standard Docker containers where PHP and Apache run together.
| Variable | Description | Default |
|----------|-------------|---------|
| `astrolabe_internal_url` | Internal URL for server-to-server requests within container | `http://localhost` |
**When to configure:**
- Custom container setups where the internal web server is not on `localhost:80`
- Kubernetes deployments with service discovery
- Multi-container setups with separate web server containers
**Example (Nextcloud config.php):**
```php
'astrolabe_internal_url' => 'http://web-server.internal:8080',
```
**Note:** This is for internal PHP-to-Apache requests, NOT for external client URLs. The default (`http://localhost`) works for standard Docker containers where PHP and Apache run together.
---
## Loading Environment Variables
After creating your `.env` file, load the environment variables:
+357
View File
@@ -0,0 +1,357 @@
# Webhook Management Guide
This guide explains how to enable and disable webhooks for vector sync in each MCP server deployment mode. Webhooks enable near-real-time synchronization of content changes to the vector database, complementing the default polling-based sync.
**Related ADRs:**
- ADR-010: Webhook-Based Vector Sync
- ADR-020: Deployment Modes and Configuration Validation
## Prerequisites
Before enabling webhooks, ensure:
1. **Nextcloud 30+** with `webhook_listeners` app enabled
2. **Astrolabe app** installed in Nextcloud (provides settings UI and credentials API)
3. **MCP server** accessible from Nextcloud via HTTP(S)
4. **Vector sync enabled** on the MCP server
## Webhook Architecture Overview
The webhook system has two components:
1. **Webhook Registration** - Configuring Nextcloud to send change notifications to the MCP server
2. **Background Sync Credentials** - Allowing the MCP server to access Nextcloud APIs on behalf of users
Both must be configured for webhooks to function properly.
## Deployment Mode Specifics
### 1. Single-User BasicAuth
**Configuration:**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
VECTOR_SYNC_ENABLED=true
```
**Enable Webhooks:**
1. Register webhooks using occ commands (requires Nextcloud admin):
```bash
# Enable webhook_listeners app
php occ app:enable webhook_listeners
# Register webhooks for vector sync
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8000/webhooks/nextcloud" \
--method POST
# Repeat for other events (see Event Types below)
```
2. Optionally reduce polling frequency:
```bash
VECTOR_SYNC_SCAN_INTERVAL=86400 # 24 hours
```
**Disable Webhooks:**
```bash
# List registered webhooks
php occ webhook_listeners:list
# Remove specific webhook by ID
php occ webhook_listeners:remove <webhook-id>
```
**Notes:**
- Simplest mode - admin credentials used for all operations
- No per-user provisioning required
- Background sync runs as the configured admin user
---
### 2. Multi-User BasicAuth Pass-Through
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
# OAuth client for Astrolabe API access
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
```
**Credential Architecture:**
This mode uses **two separate credential mechanisms**:
1. **OAuth Session** (for management API access, including webhooks):
- Obtained via browser OAuth flow (`/oauth/login`)
- Stores refresh token in MCP server's `tokens.db`
- Used for webhook registration/management APIs
2. **App Password** (for background sync):
- Generated in Nextcloud Security settings
- Stored encrypted in Nextcloud's `oc_preferences` via Astrolabe
- Used by background scanners to access Nextcloud APIs
**Enable Webhooks:**
#### Step 1: Complete OAuth Login (for Management API)
Users must authorize the MCP server to access their Nextcloud:
1. Navigate to **Nextcloud Settings → Astrolabe** (Personal settings)
2. Click **"Authorize via OAuth"** under "Option 1"
3. Complete OAuth consent flow
4. Verify the page shows "Background Sync Access: Active"
#### Step 2: Configure App Password (for Background Sync)
Since OAuth refresh tokens have short expiry, users should also configure an app password:
1. Navigate to **Nextcloud Settings → Security**
2. Generate a new app password (name it "Astrolabe" or "MCP Server")
3. Return to **Nextcloud Settings → Astrolabe**
4. Under "Option 2: App Password", paste the app password
5. Click **Save**
#### Step 3: Register Webhooks (Admin)
Same as Single-User BasicAuth:
```bash
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8003/webhooks/nextcloud" \
--method POST
```
**Disable Webhooks:**
*Per-User:*
1. Navigate to **Nextcloud Settings → Astrolabe**
2. Click **"Revoke Access"** (for OAuth tokens) or **"Revoke Access"** (for app password)
*System-Wide:*
```bash
php occ webhook_listeners:remove <webhook-id>
```
**Troubleshooting:**
If OAuth login fails with "Access forbidden - Your client is not authorized":
1. Check if OAuth client is registered:
```sql
SELECT id, name, client_identifier FROM oc_oidc_clients
WHERE dcr = 1 ORDER BY id DESC LIMIT 5;
```
2. Restart MCP server to trigger DCR re-registration
3. Verify `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` are set
If background sync fails with "User no longer provisioned":
1. Verify app password is stored:
```sql
SELECT userid, configkey FROM oc_preferences
WHERE appid = 'astrolabe' AND userid = 'username';
```
2. Ensure user completed **both** OAuth login AND app password setup
---
### 3. OAuth Single-Audience (Default OAuth Mode)
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
# No NEXTCLOUD_USERNAME/PASSWORD
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
```
**Enable Webhooks:**
#### Step 1: User Provisioning
Users authorize via OAuth with `offline_access` scope:
1. MCP client initiates OAuth flow
2. User consents to requested scopes including `offline_access`
3. MCP server stores refresh token for background operations
Alternatively, via Astrolabe UI:
1. Navigate to **Nextcloud Settings → Astrolabe**
2. Click **"Authorize via OAuth"**
3. Complete consent flow
#### Step 2: Register Webhooks (Admin)
```bash
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8001/webhooks/nextcloud" \
--method POST
```
**Disable Webhooks:**
*Per-User:*
- Via Astrolabe UI: Click "Disable Indexing" or "Disconnect"
- Via MCP tool: Use `revoke_nextcloud_access` if available
*System-Wide:*
```bash
php occ webhook_listeners:remove <webhook-id>
```
---
### 4. OAuth Token Exchange (RFC 8693)
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
```
**Enable/Disable Webhooks:**
Same process as OAuth Single-Audience. The token exchange happens transparently when the MCP server accesses Nextcloud APIs.
---
### 5. Smithery Stateless
**Configuration:**
- Configuration from session URL params
- `VECTOR_SYNC_ENABLED=false` (required)
**Webhooks:**
**Not supported.** This mode is stateless with no persistent storage or background operations.
---
## Webhook Event Types
Register these webhook events for full vector sync coverage:
### File/Note Events
```bash
# Use BeforeNodeDeletedEvent for deletions (includes node.id)
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeWrittenEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Files\Events\Node\BeforeNodeDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
### Calendar Events
```bash
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
### Tables Events
```bash
php occ webhook_listeners:add --event "OCA\Tables\Event\RowAddedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCA\Tables\Event\RowDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
## Webhook Presets (via Astrolabe UI)
The Astrolabe app provides preset webhook configurations that can be enabled/disabled via the Admin settings UI:
| Preset | Events Covered |
|--------|----------------|
| `notes_sync` | File create/update/delete for .md files |
| `calendar_sync` | Calendar object events |
| `tables_sync` | Tables row events |
| `forms_sync` | Forms submission events |
| `files_sync` | All file events (optional, high volume) |
**Enable Presets:**
1. Navigate to **Nextcloud Settings → Astrolabe** (Admin settings)
2. Toggle desired presets in "Webhook Configuration"
**Note:** Presets require the MCP server's management API to be accessible. The API uses OAuth bearer tokens from the user's session.
## Security Considerations
### Webhook Authentication
Configure `WEBHOOK_SECRET` to require authentication for incoming webhooks:
```bash
# MCP Server
WEBHOOK_SECRET=<generate-random-secret>
# Nextcloud webhook registration
php occ webhook_listeners:add \
--event "..." \
--uri "$MCP_URL/webhooks/nextcloud" \
--header "Authorization: Bearer <secret>"
```
### Token Storage
- Refresh tokens and app passwords are encrypted using `TOKEN_ENCRYPTION_KEY`
- Store the key securely (environment variable, secrets manager)
- Different users have isolated credential storage
## Monitoring
### MCP Server Logs
```bash
# Docker
docker compose logs mcp-multi-user-basic | grep -i webhook
# Key log messages
# - "Queued document from webhook: ..." - Success
# - "Webhook authentication failed" - Auth error
# - "User X no longer provisioned" - Missing credentials
```
### Nextcloud Logs
```bash
docker compose exec app cat /var/www/html/data/nextcloud.log | \
jq 'select(.message | contains("webhook"))' | tail
```
### Database Checks
```sql
-- Check registered webhooks
SELECT * FROM oc_webhook_listeners;
-- Check OAuth clients
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
-- Check user credentials in Astrolabe
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
```
## Common Issues
### "Access forbidden - Your client is not authorized to connect"
**Cause:** OAuth client registration expired or not present in Nextcloud
**Fix:** Restart MCP server to trigger DCR re-registration
### "User X no longer provisioned, stopping scanner"
**Cause:** Background sync credentials missing or expired
**Fix:** User must complete credential provisioning (see mode-specific steps)
### "Failed to fetch" in browser console during OAuth
**Cause:** Network issue between browser and MCP server callback endpoint
**Fix:** Verify MCP server is accessible at the configured `NEXTCLOUD_MCP_SERVER_URL`
### Webhooks not firing
**Causes:**
1. `webhook_listeners` app not enabled
2. Webhook not registered for the event type
3. Background job workers not running
**Fix:**
```bash
php occ app:enable webhook_listeners
php occ background:cron # or configure systemd cron
```
@@ -0,0 +1,50 @@
"""Add app_passwords table for multi-user BasicAuth mode
This migration adds support for storing app passwords that are provisioned
via Astrolabe's personal settings. This enables background sync in
multi-user BasicAuth mode without requiring OAuth.
Revision ID: 002
Revises: 001
Create Date: 2026-01-13 12:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "002"
down_revision = "001"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add app_passwords table for multi-user BasicAuth mode."""
# App passwords table for multi-user BasicAuth background sync
op.execute(
"""
CREATE TABLE IF NOT EXISTS app_passwords (
user_id TEXT PRIMARY KEY,
encrypted_password BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
# Index for efficient user lookups
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
ON app_passwords(updated_at)
"""
)
def downgrade() -> None:
"""Drop app_passwords table."""
op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated")
op.execute("DROP TABLE IF EXISTS app_passwords")
+70
View File
@@ -3,4 +3,74 @@
Provides REST endpoints for the Nextcloud PHP app to query server status,
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
authentication via the UnifiedTokenVerifier.
This package is organized into modules by domain:
- management.py: Server status, user sessions, shared helpers
- passwords.py: App password provisioning for multi-user BasicAuth
- webhooks.py: Webhook registration management
- visualization.py: Search and PDF visualization endpoints
"""
# Re-export all public functions for backward compatibility
from nextcloud_mcp_server.api.management import (
__version__,
_parse_float_param,
_parse_int_param,
_sanitize_error_for_client,
_validate_query_string,
extract_bearer_token,
get_server_status,
get_user_session,
get_vector_sync_status,
revoke_user_access,
validate_token_and_get_user,
)
from nextcloud_mcp_server.api.passwords import (
delete_app_password,
get_app_password_status,
provision_app_password,
)
from nextcloud_mcp_server.api.visualization import (
get_chunk_context,
get_pdf_preview,
unified_search,
vector_search,
)
from nextcloud_mcp_server.api.webhooks import (
create_webhook,
delete_webhook,
get_installed_apps,
list_webhooks,
)
__all__ = [
# Version
"__version__",
# Shared helpers (from management.py)
"extract_bearer_token",
"validate_token_and_get_user",
"_sanitize_error_for_client",
"_parse_int_param",
"_parse_float_param",
"_validate_query_string",
# Status endpoints (from management.py)
"get_server_status",
"get_vector_sync_status",
# Session endpoints (from management.py)
"get_user_session",
"revoke_user_access",
# Password endpoints (from passwords.py)
"provision_app_password",
"get_app_password_status",
"delete_app_password",
# Webhook endpoints (from webhooks.py)
"get_installed_apps",
"list_webhooks",
"create_webhook",
"delete_webhook",
# Visualization endpoints (from visualization.py)
"unified_search",
"vector_search",
"get_chunk_context",
"get_pdf_preview",
]
File diff suppressed because it is too large Load Diff
+429
View File
@@ -0,0 +1,429 @@
"""App password management API endpoints.
Provides REST API endpoints for app password provisioning in multi-user BasicAuth mode.
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
- Store app passwords for background sync operations
- Check app password status
- Delete stored app passwords
Authentication is via BasicAuth with the user's Nextcloud credentials.
Passwords are validated against Nextcloud before being stored.
"""
import base64
import logging
import re
import time
from collections import defaultdict
from typing import TYPE_CHECKING
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse
if TYPE_CHECKING:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
logger = logging.getLogger(__name__)
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
APP_PASSWORD_PATTERN = re.compile(
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}$"
)
# Timeout for Nextcloud API validation requests (seconds)
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
# Rate limiting configuration for app password provisioning
# Limits: 5 attempts per user per hour
RATE_LIMIT_MAX_ATTEMPTS = 5
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
# In-memory rate limiter storage
# Structure: {user_id: [(timestamp, success), ...]}
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
"""Check if user is rate limited for app password operations.
Implements a sliding window rate limiter to prevent brute-force attacks
on the app password provisioning endpoint.
Args:
user_id: User identifier to check
Returns:
Tuple of (is_allowed, seconds_until_retry)
- is_allowed: True if request should be allowed
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
"""
current_time = time.time()
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
# Clean up old attempts outside the window
_rate_limit_attempts[user_id] = [
(ts, success)
for ts, success in _rate_limit_attempts[user_id]
if ts > window_start
]
# Count recent attempts (both successful and failed)
recent_attempts = len(_rate_limit_attempts[user_id])
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
# Find when the oldest attempt in the window will expire
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
seconds_until_retry = int(
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
)
return False, max(1, seconds_until_retry)
return True, 0
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
"""Record an app password provisioning attempt for rate limiting.
Args:
user_id: User identifier
success: Whether the attempt was successful
"""
_rate_limit_attempts[user_id].append((time.time(), success))
def _extract_basic_auth(
request: Request, path_user_id: str
) -> tuple[str, str, JSONResponse | None]:
"""Extract and validate BasicAuth credentials from request.
Validates:
1. Authorization header is present and valid BasicAuth format
2. Username in credentials matches the path user_id
Args:
request: Starlette request with Authorization header
path_user_id: User ID from the URL path to verify against
Returns:
Tuple of (username, password, error_response)
- If successful: (username, password, None)
- If failed: ("", "", JSONResponse with error)
"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Basic "):
return (
"",
"",
JSONResponse(
{"success": False, "error": "Missing BasicAuth credentials"},
status_code=401,
),
)
try:
# Decode BasicAuth
encoded = auth_header.split(" ", 1)[1]
decoded = base64.b64decode(encoded).decode("utf-8")
username, password = decoded.split(":", 1)
except Exception:
return (
"",
"",
JSONResponse(
{"success": False, "error": "Invalid BasicAuth format"},
status_code=401,
),
)
# Verify username matches path user_id
if username != path_user_id:
logger.warning(
f"Username mismatch in app password operation for path user {path_user_id}"
)
return (
"",
"",
JSONResponse(
{"success": False, "error": "Username does not match path user_id"},
status_code=403,
),
)
return username, password, None
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
"""Get or initialize RefreshTokenStorage for app password operations.
Checks app.state.storage first, then falls back to creating from environment.
This helper avoids repeated storage initialization logic across endpoints.
Args:
request: Starlette request with app state
Returns:
Initialized RefreshTokenStorage instance
"""
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = getattr(request.app.state, "storage", None)
if not storage:
# Multi-user BasicAuth mode may not have oauth_context
# Initialize storage from environment
storage = RefreshTokenStorage.from_env()
await storage.initialize()
return storage
async def provision_app_password(request: Request) -> JSONResponse:
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
for multi-user BasicAuth mode background sync.
The request must include BasicAuth credentials where:
- username: Nextcloud user ID (must match path user_id)
- password: The app password being provisioned
The MCP server validates the app password against Nextcloud before storing it.
This proves the user owns the password and has access to Nextcloud.
Security model:
- User identity is verified via BasicAuth against Nextcloud
- App password is encrypted before storage
- Only the user who owns the password can provision it
- Rate limited to prevent brute-force attacks
"""
from nextcloud_mcp_server.config import get_settings
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Check rate limit before processing
is_allowed, retry_after = _check_rate_limit(path_user_id)
if not is_allowed:
logger.warning(
f"Rate limit exceeded for app password provisioning: {path_user_id}"
)
return JSONResponse(
{
"success": False,
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
},
status_code=429,
headers={"Retry-After": str(retry_after)},
)
# Extract and validate BasicAuth credentials
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
_record_rate_limit_attempt(path_user_id, success=False)
return error_response
# Validate app password format
if not APP_PASSWORD_PATTERN.match(app_password):
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "Invalid app password format"},
status_code=400,
)
# Get Nextcloud host from settings
settings = get_settings()
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
logger.error("NEXTCLOUD_HOST not configured")
return JSONResponse(
{"success": False, "error": "Server not configured"},
status_code=500,
)
# Validate app password against Nextcloud
try:
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
# Use OCS API to verify credentials
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
response = await client.get(
test_url,
auth=(username, app_password),
params={"format": "json"},
headers={"OCS-APIRequest": "true"},
)
if response.status_code != 200:
logger.warning(
f"App password validation failed for user: HTTP {response.status_code}"
)
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "Invalid app password"},
status_code=401,
)
# Verify the user ID from response matches
data = response.json()
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
if ocs_user_id != username:
logger.warning("User ID mismatch in OCS response")
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "User ID mismatch"},
status_code=403,
)
except httpx.RequestError as e:
logger.error(f"Failed to validate app password: {e}")
return JSONResponse(
{"success": False, "error": "Failed to validate credentials"},
status_code=500,
)
# Store the validated app password
try:
storage = await _get_app_password_storage(request)
await storage.store_app_password(username, app_password)
_record_rate_limit_attempt(path_user_id, success=True)
logger.info(f"Provisioned app password for user: {username}")
return JSONResponse(
{
"success": True,
"message": f"App password stored for {username}",
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "provision_app_password")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def get_app_password_status(request: Request) -> JSONResponse:
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
Returns status of background sync access for multi-user BasicAuth mode.
Requires BasicAuth with the user's app password for authentication.
"""
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Extract and validate BasicAuth credentials
username, _, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
try:
storage = await _get_app_password_storage(request)
app_password = await storage.get_app_password(username)
return JSONResponse(
{
"success": True,
"user_id": username,
"has_app_password": app_password is not None,
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def delete_app_password(request: Request) -> JSONResponse:
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
Removes the user's app password from MCP server storage.
Requires BasicAuth with the user's credentials.
"""
from nextcloud_mcp_server.config import get_settings
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Extract and validate BasicAuth credentials
username, password, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
# Validate credentials against Nextcloud
settings = get_settings()
nextcloud_host = settings.nextcloud_host
try:
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
response = await client.get(
test_url,
auth=(username, password),
params={"format": "json"},
headers={"OCS-APIRequest": "true"},
)
if response.status_code != 200:
return JSONResponse(
{"success": False, "error": "Invalid credentials"},
status_code=401,
)
except httpx.RequestError as e:
logger.error(f"Failed to validate credentials: {e}")
return JSONResponse(
{"success": False, "error": "Failed to validate credentials"},
status_code=500,
)
try:
storage = await _get_app_password_storage(request)
deleted = await storage.delete_app_password(username)
if deleted:
logger.info(f"Deleted app password for user: {username}")
return JSONResponse(
{
"success": True,
"message": f"App password deleted for {username}",
}
)
else:
return JSONResponse(
{
"success": True,
"message": "No app password found to delete",
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "delete_app_password")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
+813
View File
@@ -0,0 +1,813 @@
"""Visualization API endpoints for search and PDF preview.
ADR-018: Provides REST API endpoints for the Nextcloud PHP app (Astrolabe) to:
- Execute unified search with semantic/BM25/hybrid algorithms
- Execute vector search with PCA visualization coordinates
- Fetch chunk context with surrounding text
- Render PDF pages server-side (avoiding CSP/worker issues)
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
"""
import base64
import logging
from typing import TYPE_CHECKING, Any
import pymupdf
if TYPE_CHECKING:
pass
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import (
_parse_float_param,
_parse_int_param,
_sanitize_error_for_client,
_validate_query_string,
extract_bearer_token,
validate_token_and_get_user,
)
logger = logging.getLogger(__name__)
async def unified_search(request: Request) -> JSONResponse:
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
Optimized search endpoint for the Nextcloud Unified Search provider
and other PHP app integrations. Returns results with metadata needed
for navigation to source documents.
Request body:
{
"query": "search query",
"algorithm": "semantic|bm25|hybrid", // default: hybrid
"limit": 20, // max: 100
"offset": 0, // pagination offset
"include_pca": false, // optional PCA coordinates
"include_chunks": true // include text snippets
}
Response:
{
"results": [{
"id": "doc123",
"doc_type": "note",
"title": "Document Title",
"excerpt": "Matching text snippet...",
"score": 0.85,
"path": "/path/to/file.txt", // for files
"board_id": 1, // for deck cards
"card_id": 42
}],
"total_found": 150,
"algorithm_used": "hybrid"
}
Requires OAuth bearer token for user filtering.
"""
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
{"error": "Vector sync is disabled on this server"},
status_code=404,
)
# Validate OAuth token and extract user
try:
user_id, _validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/search: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "unified_search"),
},
status_code=401,
)
try:
# Parse request body
body = await request.json()
# Validate and parse parameters
try:
query = body.get("query", "")
_validate_query_string(query, max_length=10000)
limit = _parse_int_param(
str(body.get("limit")) if body.get("limit") is not None else None,
20,
1,
100,
"limit",
)
offset = _parse_int_param(
str(body.get("offset")) if body.get("offset") is not None else None,
0,
0,
1000000,
"offset",
)
score_threshold = _parse_float_param(
body.get("score_threshold"),
0.0,
0.0,
1.0,
"score_threshold",
)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
algorithm = body.get("algorithm", "hybrid")
fusion = body.get("fusion", "rrf")
include_pca = body.get("include_pca", False)
include_chunks = body.get("include_chunks", True)
doc_types = body.get("doc_types") # Optional filter
if not query:
return JSONResponse({"results": [], "total_found": 0})
# Validate algorithm
valid_algorithms = {"semantic", "bm25", "hybrid"}
if algorithm not in valid_algorithms:
algorithm = "hybrid"
# Validate fusion method
valid_fusions = {"rrf", "dbsf"}
if fusion not in valid_fusions:
fusion = "rrf"
# Execute search using the appropriate algorithm
from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
)
# Select search algorithm
if algorithm == "semantic":
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
else:
search_algo = BM25HybridSearchAlgorithm(
score_threshold=score_threshold, fusion=fusion
)
# Request extra results to handle offset
search_limit = limit + offset
# Execute search
all_results = []
if doc_types and isinstance(doc_types, list):
for doc_type in doc_types:
if doc_type:
results = await search_algo.search(
query=query,
user_id=user_id,
limit=search_limit,
doc_type=doc_type,
)
all_results.extend(results)
all_results.sort(key=lambda r: r.score, reverse=True)
else:
all_results = await search_algo.search(
query=query,
user_id=user_id,
limit=search_limit,
)
# Sort results by score (no deduplication - show all chunks)
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
# Calculate total and apply pagination
total_found = len(sorted_results)
paginated_results = sorted_results[offset : offset + limit]
# Format results for Unified Search
formatted_results = []
for result in paginated_results:
# Get document ID (prefer note_id for notes)
doc_id = result.id
if result.metadata and "note_id" in result.metadata:
doc_id = result.metadata["note_id"]
result_data: dict[str, Any] = {
"id": doc_id,
"doc_type": result.doc_type,
"title": result.title,
"score": result.score,
}
# Include excerpt/chunk if requested (full content, no truncation)
if include_chunks and result.excerpt:
result_data["excerpt"] = result.excerpt
# Include navigation metadata from result.metadata
if result.metadata:
# File path and mimetype for files
if "path" in result.metadata:
result_data["path"] = result.metadata["path"]
if "mime_type" in result.metadata:
result_data["mime_type"] = result.metadata["mime_type"]
# Deck card navigation
if "board_id" in result.metadata:
result_data["board_id"] = result.metadata["board_id"]
if "card_id" in result.metadata:
result_data["card_id"] = result.metadata["card_id"]
# Calendar event metadata
if "calendar_id" in result.metadata:
result_data["calendar_id"] = result.metadata["calendar_id"]
if "event_uid" in result.metadata:
result_data["event_uid"] = result.metadata["event_uid"]
# Add PDF page metadata
if result.page_number is not None:
result_data["page_number"] = result.page_number
if result.page_count is not None:
result_data["page_count"] = result.page_count
# Add chunk metadata (always present, defaults to 0 and 1)
result_data["chunk_index"] = result.chunk_index
result_data["total_chunks"] = result.total_chunks
# Add chunk offsets for modal navigation
if result.chunk_start_offset is not None:
result_data["chunk_start_offset"] = result.chunk_start_offset
if result.chunk_end_offset is not None:
result_data["chunk_end_offset"] = result.chunk_end_offset
formatted_results.append(result_data)
response_data: dict[str, Any] = {
"results": formatted_results,
"total_found": total_found,
"algorithm_used": algorithm,
}
# Optional PCA coordinates
if include_pca and len(paginated_results) >= 2:
try:
from nextcloud_mcp_server.vector.visualization import (
compute_pca_coordinates,
)
if search_algo.query_embedding is not None:
query_embedding = search_algo.query_embedding
else:
from nextcloud_mcp_server.embedding.service import (
get_embedding_service,
)
embedding_service = get_embedding_service()
query_embedding = await embedding_service.embed(query)
pca_data = await compute_pca_coordinates(
paginated_results, query_embedding
)
response_data["pca_data"] = pca_data
except Exception as e:
logger.warning(f"Failed to compute PCA for unified search: {e}")
return JSONResponse(response_data)
except Exception as e:
logger.error(f"Error in unified search: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "unified_search"),
},
status_code=500,
)
async def vector_search(request: Request) -> JSONResponse:
"""POST /api/v1/vector-viz/search - Vector search for visualization.
Executes semantic search and returns results with optional PCA coordinates
for 2D visualization.
Request body:
{
"query": "search query",
"algorithm": "semantic|bm25|hybrid", // default: hybrid
"limit": 10, // max: 50
"include_pca": true, // whether to include 2D coordinates
"doc_types": ["note", "file"] // optional filter by document types
}
Requires OAuth bearer token for user filtering.
"""
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
{"error": "Vector sync is disabled on this server"},
status_code=404,
)
# Validate OAuth token and extract user
try:
user_id, _validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/vector-viz/search: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "vector_search"),
},
status_code=401,
)
try:
# Parse request body
body = await request.json()
query = body.get("query", "")
algorithm = body.get("algorithm", "hybrid")
fusion = body.get("fusion", "rrf")
score_threshold = body.get("score_threshold", 0.0)
limit = min(body.get("limit", 10), 50) # Enforce max limit
include_pca = body.get("include_pca", True)
doc_types = body.get("doc_types") # Optional list of document types
if not query:
return JSONResponse(
{"error": "Missing required parameter: query"},
status_code=400,
)
# Validate algorithm
valid_algorithms = {"semantic", "bm25", "hybrid"}
if algorithm not in valid_algorithms:
algorithm = "hybrid"
# Validate fusion method
valid_fusions = {"rrf", "dbsf"}
if fusion not in valid_fusions:
fusion = "rrf"
# Execute search using the appropriate algorithm
from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
)
# Select search algorithm
if algorithm == "semantic":
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
else:
# Both "hybrid" and "bm25" use the BM25HybridSearchAlgorithm
# which combines dense semantic and sparse BM25 vectors
search_algo = BM25HybridSearchAlgorithm(
score_threshold=score_threshold, fusion=fusion
)
# Execute search for each doc_type if specified, otherwise search all
all_results = []
if doc_types and isinstance(doc_types, list):
# Search each doc_type separately and merge results
for doc_type in doc_types:
if doc_type: # Skip empty strings
results = await search_algo.search(
query=query,
user_id=user_id,
limit=limit,
doc_type=doc_type,
)
all_results.extend(results)
# Sort merged results by score and limit
all_results.sort(key=lambda r: r.score, reverse=True)
all_results = all_results[:limit]
else:
# Search all document types
all_results = await search_algo.search(
query=query,
user_id=user_id,
limit=limit,
)
# Format results for PHP client
formatted_results = []
for result in all_results:
formatted_result = {
"id": result.id,
"doc_type": result.doc_type,
"title": result.title,
"excerpt": result.excerpt[:200] if result.excerpt else "",
"score": result.score,
"metadata": result.metadata,
# Chunk information for context display
"chunk_index": result.chunk_index,
"total_chunks": result.total_chunks,
}
# Include optional fields if present
if result.chunk_start_offset is not None:
formatted_result["chunk_start_offset"] = result.chunk_start_offset
if result.chunk_end_offset is not None:
formatted_result["chunk_end_offset"] = result.chunk_end_offset
if result.page_number is not None:
formatted_result["page_number"] = result.page_number
if result.page_count is not None:
formatted_result["page_count"] = result.page_count
formatted_results.append(formatted_result)
response_data: dict[str, Any] = {
"results": formatted_results,
"algorithm_used": algorithm,
"total_documents": len(formatted_results),
}
# Compute PCA coordinates for visualization using shared function
if include_pca and len(all_results) >= 2:
try:
from nextcloud_mcp_server.vector.visualization import (
compute_pca_coordinates,
)
# Get query embedding from search algorithm or generate it
if search_algo.query_embedding is not None:
query_embedding = search_algo.query_embedding
else:
from nextcloud_mcp_server.embedding.service import (
get_embedding_service,
)
embedding_service = get_embedding_service()
query_embedding = await embedding_service.embed(query)
pca_data = await compute_pca_coordinates(all_results, query_embedding)
response_data["coordinates_3d"] = pca_data["coordinates_3d"]
response_data["query_coords"] = pca_data["query_coords"]
if "pca_variance" in pca_data:
response_data["pca_variance"] = pca_data["pca_variance"]
except Exception as e:
logger.warning(f"Failed to compute PCA coordinates: {e}")
response_data["coordinates_3d"] = []
response_data["query_coords"] = []
elif include_pca:
# Not enough results for PCA
response_data["coordinates_3d"] = []
response_data["query_coords"] = []
return JSONResponse(response_data)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "vector_search")
return JSONResponse(
{"error": error_msg},
status_code=500,
)
async def get_chunk_context(request: Request) -> JSONResponse:
"""GET /api/v1/chunk-context - Fetch chunk text with context.
Retrieves the matched chunk along with surrounding text and metadata.
Used by clients to display chunk context and highlighted PDFs.
Query parameters:
doc_type: Document type (e.g., "note")
doc_id: Document ID
start: Chunk start offset (character position)
end: Chunk end offset (character position)
context: Characters of context before/after (default: 500)
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/chunk-context: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "get_chunk_context"),
},
status_code=401,
)
try:
# Get query parameters
doc_type = request.query_params.get("doc_type")
doc_id = request.query_params.get("doc_id")
start_str = request.query_params.get("start")
end_str = request.query_params.get("end")
# Validate required parameters
if not all([doc_type, doc_id, start_str, end_str]):
return JSONResponse(
{
"success": False,
"error": "Missing required parameters: doc_type, doc_id, start, end",
},
status_code=400,
)
# Type narrowing: we already checked these are not None above
assert start_str is not None
assert end_str is not None
assert doc_id is not None
assert doc_type is not None
# Parse and validate integer parameters with bounds checking
try:
context_chars = _parse_int_param(
request.query_params.get("context"),
500,
0,
10000,
"context_chars",
)
start = _parse_int_param(start_str, 0, 0, 10000000, "start")
end = _parse_int_param(end_str, 0, 0, 10000000, "end")
if end <= start:
raise ValueError("end must be greater than start")
except ValueError as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
# Convert doc_id to int if possible (most IDs are int)
doc_id_val: str | int = int(doc_id) if doc_id.isdigit() else doc_id
# Get bearer token for client initialization
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing token")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Initialize authenticated Nextcloud client
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.search.context import get_chunk_with_context
async with NextcloudClient.from_token(
base_url=nextcloud_host, token=token, username=user_id
) as nc_client:
chunk_context = await get_chunk_with_context(
nc_client=nc_client,
user_id=user_id,
doc_id=doc_id_val,
doc_type=doc_type,
chunk_start=start,
chunk_end=end,
context_chars=context_chars,
)
if chunk_context is None:
return JSONResponse(
{
"success": False,
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
},
status_code=404,
)
# For PDF files, also fetch the highlighted page image from Qdrant if available
# This is useful for clients that want to show a pre-rendered image
highlighted_page_image = None
page_number = chunk_context.page_number
if doc_type == "file":
try:
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
settings = get_settings()
qdrant_client = await get_qdrant_client()
# Query for this specific chunk's highlighted image
points_response = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
get_placeholder_filter(),
FieldCondition(
key="doc_id", match=MatchValue(value=doc_id_val)
),
FieldCondition(
key="user_id", match=MatchValue(value=user_id)
),
FieldCondition(
key="chunk_start_offset", match=MatchValue(value=start)
),
FieldCondition(
key="chunk_end_offset", match=MatchValue(value=end)
),
]
),
limit=1,
with_vectors=False,
with_payload=["highlighted_page_image", "page_number"],
)
if points_response[0]:
payload = points_response[0][0].payload
if payload:
highlighted_page_image = payload.get("highlighted_page_image")
# Trust Qdrant page number if available (might be more accurate than context expansion logic)
if payload.get("page_number") is not None:
page_number = payload.get("page_number")
except Exception as e:
logger.warning(f"Failed to fetch highlighted image: {e}")
# Build response
response_data = {
"success": True,
"chunk_text": chunk_context.chunk_text,
"before_context": chunk_context.before_context,
"after_context": chunk_context.after_context,
"has_more_before": chunk_context.has_before_truncation,
"has_more_after": chunk_context.has_after_truncation,
"page_number": page_number,
"chunk_index": chunk_context.chunk_index,
"total_chunks": chunk_context.total_chunks,
}
if highlighted_page_image:
response_data["highlighted_page_image"] = highlighted_page_image
return JSONResponse(response_data)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_chunk_context")
return JSONResponse(
{"error": error_msg},
status_code=500,
)
async def get_pdf_preview(request: Request) -> JSONResponse:
"""GET /api/v1/pdf-preview - Render PDF page to PNG image.
Server-side PDF rendering using PyMuPDF. This endpoint allows Astrolabe
to display PDF pages without requiring client-side PDF.js, avoiding CSP
worker restrictions and ES private field issues in Chromium.
Query parameters:
file_path: WebDAV path to PDF file (e.g., "/Documents/report.pdf")
page: Page number (1-indexed, default: 1)
scale: Zoom factor for rendering (default: 2.0 = 144 DPI)
Returns:
{
"success": true,
"image": "<base64-encoded-png>",
"page_number": 1,
"total_pages": 10
}
Requires OAuth bearer token for authentication.
"""
# Log incoming request
file_path_param = request.query_params.get("file_path", "<not provided>")
page_param = request.query_params.get("page", "1")
logger.info(f"PDF preview request: file_path={file_path_param}, page={page_param}")
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
logger.info(f"PDF preview authenticated for user: {user_id}")
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/pdf-preview: {e}")
return JSONResponse(
{
"success": False,
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "get_pdf_preview"),
},
status_code=401,
)
try:
# Parse and validate parameters
file_path = request.query_params.get("file_path")
if not file_path:
return JSONResponse(
{"success": False, "error": "Missing required parameter: file_path"},
status_code=400,
)
# Validate no path traversal sequences
if ".." in file_path:
return JSONResponse(
{"success": False, "error": "Invalid file path"},
status_code=400,
)
try:
page_num = _parse_int_param(
request.query_params.get("page"), 1, 1, 10000, "page"
)
scale = _parse_float_param(
request.query_params.get("scale"), 2.0, 0.5, 5.0, "scale"
)
except ValueError as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
# Get bearer token for WebDAV authentication
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing token")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Download PDF via WebDAV using user's token
from nextcloud_mcp_server.client import NextcloudClient
async with NextcloudClient.from_token(
base_url=nextcloud_host, token=token, username=user_id
) as nc_client:
pdf_bytes, _ = await nc_client.webdav.read_file(file_path)
# Check file size limit (50 MB)
max_pdf_size = 50 * 1024 * 1024
if len(pdf_bytes) > max_pdf_size:
return JSONResponse(
{
"success": False,
"error": f"PDF file exceeds maximum size limit ({max_pdf_size // (1024 * 1024)} MB)",
},
status_code=413,
)
# Render page with PyMuPDF
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
try:
total_pages = doc.page_count
# Validate page number
if page_num > total_pages:
return JSONResponse(
{
"success": False,
"error": f"Page {page_num} does not exist (document has {total_pages} pages)",
},
status_code=400,
)
page = doc[page_num - 1] # 0-indexed
mat = pymupdf.Matrix(scale, scale)
pix = page.get_pixmap(matrix=mat, alpha=False)
png_bytes = pix.tobytes("png")
finally:
doc.close()
# Encode as base64
image_b64 = base64.b64encode(png_bytes).decode("ascii")
logger.info(
f"Rendered PDF preview: {file_path} page {page_num}/{total_pages}, "
f"{len(png_bytes):,} bytes"
)
return JSONResponse(
{
"success": True,
"image": image_b64,
"page_number": page_num,
"total_pages": total_pages,
}
)
except FileNotFoundError:
logger.warning(f"PDF file not found: {file_path_param}")
return JSONResponse(
{"success": False, "error": "PDF file not found"},
status_code=404,
)
except (pymupdf.FileDataError, pymupdf.EmptyFileError):
logger.warning(f"Invalid or corrupted PDF file: {file_path_param}")
return JSONResponse(
{"success": False, "error": "Invalid or corrupted PDF file"},
status_code=400,
)
except Exception as e:
logger.error(f"PDF preview error: {e}", exc_info=True)
error_msg = _sanitize_error_for_client(e, "get_pdf_preview")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
+308
View File
@@ -0,0 +1,308 @@
"""Webhook management API endpoints.
Provides REST API endpoints for managing webhook registrations with Nextcloud.
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
- List installed Nextcloud apps
- Create, list, and delete webhook registrations
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
"""
import logging
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import (
_sanitize_error_for_client,
extract_bearer_token,
validate_token_and_get_user,
)
logger = logging.getLogger(__name__)
async def get_installed_apps(request: Request) -> JSONResponse:
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
Returns a list of installed app IDs for filtering webhook presets.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/apps: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "get_installed_apps"),
},
status_code=401,
)
try:
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with httpx.AsyncClient(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Get installed apps using OCS API
# Notes, Calendar, Deck, Tables, etc. are apps that support webhooks
# We check which ones are installed and enabled
ocs_url = "/ocs/v1.php/cloud/apps"
params = {"filter": "enabled"}
response = await client.get(
ocs_url,
params=params,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
if response.status_code != 200:
raise ValueError(f"OCS API returned status {response.status_code}")
data = response.json()
apps = data.get("ocs", {}).get("data", {}).get("apps", [])
return JSONResponse({"apps": apps})
except Exception as e:
logger.error(f"Error getting installed apps for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "get_installed_apps"),
},
status_code=500,
)
async def list_webhooks(request: Request) -> JSONResponse:
"""GET /api/v1/webhooks - List all registered webhooks.
Returns list of webhook registrations for the authenticated user.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "list_webhooks"),
},
status_code=401,
)
try:
from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with httpx.AsyncClient(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Use WebhooksClient to list webhooks
webhooks_client = WebhooksClient(client, user_id)
webhooks = await webhooks_client.list_webhooks()
return JSONResponse({"webhooks": webhooks})
except Exception as e:
logger.error(f"Error listing webhooks for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "list_webhooks"),
},
status_code=500,
)
async def create_webhook(request: Request) -> JSONResponse:
"""POST /api/v1/webhooks - Create a new webhook registration.
Request body:
{
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
"uri": "http://mcp:8000/webhooks/nextcloud",
"eventFilter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"}
}
Returns the created webhook data including the webhook ID.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "create_webhook"),
},
status_code=401,
)
try:
from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Parse request body
body = await request.json()
event = body.get("event")
uri = body.get("uri")
# Accept both camelCase (eventFilter) and snake_case (event_filter)
event_filter = body.get("eventFilter") or body.get("event_filter")
if not event or not uri:
return JSONResponse(
{
"error": "Bad request",
"message": "Missing required fields: event, uri",
},
status_code=400,
)
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with httpx.AsyncClient(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Use WebhooksClient to create webhook
webhooks_client = WebhooksClient(client, user_id)
webhook_data = await webhooks_client.create_webhook(
event=event, uri=uri, event_filter=event_filter
)
return JSONResponse({"webhook": webhook_data})
except Exception as e:
logger.error(f"Error creating webhook for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "create_webhook"),
},
status_code=500,
)
async def delete_webhook(request: Request) -> JSONResponse:
"""DELETE /api/v1/webhooks/{webhook_id} - Delete a webhook registration.
Returns success/failure status.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "delete_webhook"),
},
status_code=401,
)
try:
from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Get webhook_id from path parameter
webhook_id = request.path_params.get("webhook_id")
if not webhook_id:
return JSONResponse(
{"error": "Bad request", "message": "Missing webhook_id"},
status_code=400,
)
try:
webhook_id = int(webhook_id)
except ValueError:
return JSONResponse(
{"error": "Bad request", "message": "Invalid webhook_id"},
status_code=400,
)
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with httpx.AsyncClient(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Use WebhooksClient to delete webhook
webhooks_client = WebhooksClient(client, user_id)
await webhooks_client.delete_webhook(webhook_id=webhook_id)
return JSONResponse({"success": True, "message": "Webhook deleted"})
except Exception as e:
logger.error(f"Error deleting webhook for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "delete_webhook"),
},
status_code=500,
)
+355 -143
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(
@@ -1846,7 +2012,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
checks["auth_mode"] = "multi_user_basic"
checks["auth_configured"] = "ok"
# Indicate if app passwords are supported (when offline_access enabled)
checks["supports_app_passwords"] = settings.enable_offline_access
checks["supports_app_passwords"] = get_settings().enable_offline_access
elif mode == AuthMode.SINGLE_USER_BASIC:
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
@@ -1863,9 +2029,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Check Qdrant status if using network mode (external Qdrant service)
# In-memory and persistent modes use embedded Qdrant, no external service to check
vector_sync_enabled = (
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
)
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
settings = get_settings()
vector_sync_enabled = settings.vector_sync_enabled
qdrant_url = os.getenv("QDRANT_URL") # Only set in network mode
if vector_sync_enabled and qdrant_url:
@@ -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()
@@ -1947,15 +2112,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
settings.enable_multi_user_basic_auth and settings.enable_offline_access
)
if enable_management_apis:
from nextcloud_mcp_server.api.management import (
from nextcloud_mcp_server.api import (
create_webhook,
delete_app_password,
delete_webhook,
get_app_password_status,
get_chunk_context,
get_installed_apps,
get_pdf_preview,
get_server_status,
get_user_session,
get_vector_sync_status,
list_webhooks,
provision_app_password,
revoke_user_access,
unified_search,
vector_search,
@@ -1983,12 +2152,36 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
methods=["POST"],
)
)
# App password endpoints for multi-user BasicAuth mode
routes.append(
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
)
)
routes.append(
Route(
"/api/v1/users/{user_id}/app-password",
get_app_password_status,
methods=["GET"],
)
)
routes.append(
Route(
"/api/v1/users/{user_id}/app-password",
delete_app_password,
methods=["DELETE"],
)
)
routes.append(
Route("/api/v1/vector-viz/search", vector_search, methods=["POST"])
)
routes.append(
Route("/api/v1/chunk-context", get_chunk_context, methods=["GET"])
)
# PDF preview endpoint for Astrolabe (server-side rendering)
routes.append(Route("/api/v1/pdf-preview", get_pdf_preview, methods=["GET"]))
# ADR-018: Unified search endpoint for Nextcloud PHP app integration
routes.append(Route("/api/v1/search", unified_search, methods=["POST"]))
routes.append(Route("/api/v1/apps", get_installed_apps, methods=["GET"]))
@@ -2001,8 +2194,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
logger.info(
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
"/api/v1/users/{user_id}/app-password, "
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
"/api/v1/webhooks"
"/api/v1/webhooks, /api/v1/pdf-preview"
)
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
@@ -2028,7 +2222,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 +2299,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 +2328,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 +2495,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:
+175 -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())
@@ -1240,6 +1240,180 @@ class RefreshTokenStorage:
return deleted
# ============================================================================
# App Password Storage (multi-user BasicAuth mode)
# ============================================================================
async def store_app_password(
self,
user_id: str,
app_password: str,
) -> None:
"""
Store encrypted app password for background sync (multi-user BasicAuth mode).
Args:
user_id: Nextcloud user ID
app_password: Nextcloud app password to store
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password storage."
)
encrypted_password = self.cipher.encrypt(app_password.encode())
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO app_passwords
(user_id, encrypted_password, created_at, updated_at)
VALUES (
?,
?,
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
?
)
""",
(user_id, encrypted_password, user_id, now, now),
)
await db.commit()
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(f"Stored app password for user {user_id}")
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "error")
raise
# Audit log
await self._audit_log(
event="store_app_password",
user_id=user_id,
auth_method="app_password",
)
async def get_app_password(self, user_id: str) -> Optional[str]:
"""
Retrieve and decrypt app password for a user.
Args:
user_id: Nextcloud user ID
Returns:
Decrypted app password, or None if not found
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
)
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug(f"No app password found for user {user_id}")
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
encrypted_password = row[0]
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
logger.debug(f"Retrieved app password for user {user_id}")
return decrypted_password
except Exception as e:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
return None
async def delete_app_password(self, user_id: str) -> bool:
"""
Delete app password for a user.
Args:
user_id: Nextcloud user ID
Returns:
True if password was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM app_passwords WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "success")
if deleted:
logger.info(f"Deleted app password for user {user_id}")
await self._audit_log(
event="delete_app_password",
user_id=user_id,
auth_method="app_password",
)
else:
logger.debug(f"No app password to delete for user {user_id}")
return deleted
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "error")
raise
async def get_all_app_password_user_ids(self) -> list[str]:
"""
Get list of all user IDs with stored app passwords.
Returns:
List of user IDs
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT user_id FROM app_passwords ORDER BY updated_at DESC"
) as cursor:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
logger.debug(f"Found {len(user_ids)} users with app passwords")
return user_ids
async def generate_encryption_key() -> str:
"""
+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,
+8 -8
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
@@ -19,6 +20,7 @@ from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
@@ -105,9 +107,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
"status": str, # "syncing" or "idle"
}
"""
# Check if vector sync is enabled
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
if not vector_sync_enabled:
# Check if vector sync is enabled (supports both old and new env var names)
settings = get_settings()
if not settings.vector_sync_enabled:
return None
try:
@@ -126,10 +128,8 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
# Get Qdrant client and query indexed count
indexed_count = 0
try:
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
settings = get_settings()
qdrant_client = await get_qdrant_client()
# Count documents in collection
@@ -385,8 +385,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 {
@@ -635,7 +633,9 @@ async def user_info_html(request: Request) -> HTMLResponse:
"""
# Check if vector sync is enabled (needed for Welcome tab)
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
settings = get_settings()
vector_sync_enabled = settings.vector_sync_enabled
# Render template
template = _jinja_env.get_template("user_info.html")
+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={
+22 -21
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:
@@ -391,11 +386,17 @@ class DeckClient(BaseNextcloudClient):
order: int,
target_stack_id: int,
) -> None:
# Use the non-API route /cards/{cardId}/reorder which correctly reads
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
# has a parameter conflict where URL stackId overrides body stackId.
# See: https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
json_data = {"order": order, "stackId": target_stack_id}
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
f"/apps/deck/cards/{card_id}/reorder",
json=json_data,
headers=headers,
)
# Labels
+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:
+6 -2
View File
@@ -637,7 +637,9 @@ def configure_deck_tools(mcp: FastMCP):
@mcp.tool(
title="Remove Label from Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@instrument_tool
@@ -692,7 +694,9 @@ def configure_deck_tools(mcp: FastMCP):
@mcp.tool(
title="Unassign User from Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@instrument_tool
+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
+4 -6
View File
@@ -656,14 +656,12 @@ 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 = (
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
)
# Check if vector sync is enabled (supports both old and new env var names)
from nextcloud_mcp_server.config import get_settings
if not vector_sync_enabled:
settings = get_settings()
if not settings.vector_sync_enabled:
return VectorSyncStatusResponse(
indexed_count=0,
pending_count=0,
+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
+180 -84
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 stored locally in MCP server's database
- Users provision via Astrolabe personal settings, which sends to MCP API
- 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
@@ -24,7 +33,6 @@ from anyio.streams.memory import (
)
from httpx import BasicAuth
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
@@ -59,16 +67,61 @@ 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,
storage: "RefreshTokenStorage | None" = None,
) -> NextcloudClient:
"""Get an authenticated NextcloudClient using app password (BasicAuth mode).
For multi-user BasicAuth deployments where users provision app passwords
via Astrolabe personal settings. The app password is stored locally in the
MCP server's database after being provisioned through the management API.
Args:
user_id: User identifier
nextcloud_host: Nextcloud base URL
storage: Optional RefreshTokenStorage instance (created from env if not provided)
Returns:
Authenticated NextcloudClient with BasicAuth
Raises:
NotProvisionedError: If user has not provisioned an app password
"""
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
# Get or create storage instance
if storage is None:
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Retrieve app password from local storage
app_password = await storage.get_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 +129,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 +149,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 +216,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 +230,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 +250,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 +290,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 +305,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 +319,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 +345,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 +364,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,48 +377,60 @@ 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():
try:
# Get current provisioned users
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
# Get current provisioned users based on mode
if use_basic_auth:
# BasicAuth mode: query app_passwords table
provisioned_users = set(
await refresh_token_storage.get_all_app_password_user_ids()
)
else:
# OAuth mode: query refresh_tokens table
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
active_users = set(user_states.keys())
# Start scanners for new users
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 +449,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 +480,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(
+3 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.57.0"
version = "0.63.1"
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"
@@ -114,7 +114,7 @@ caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
[build-system]
requires = ["uv_build>=0.9.4,<0.10.0"]
requires = ["uv_build>=0.10.0,<0.11.0"]
build-backend = "uv_build"
[tool.uv.build-backend]
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
Database query helper for development.
Wraps `docker compose exec db mariadb` to execute SQL statements against
the Nextcloud MariaDB database.
Usage:
./scripts/dbquery.py "SELECT * FROM oc_notes LIMIT 5"
./scripts/dbquery.py -u root -p password "SHOW TABLES"
./scripts/dbquery.py --json "SELECT * FROM oc_oidc_clients"
"""
import argparse
import subprocess
import sys
from pathlib import Path
def find_compose_dir() -> Path:
"""Find the directory containing docker-compose.yml."""
current = Path(__file__).resolve().parent
while current != current.parent:
if (current / "docker-compose.yml").exists():
return current
if (current / "compose.yml").exists():
return current
current = current.parent
# Default to script's parent directory
return Path(__file__).resolve().parent.parent
def run_query(
sql: str,
user: str = "root",
password: str = "password",
database: str = "nextcloud",
vertical: bool = False,
json_output: bool = False,
) -> tuple[int, str, str]:
"""
Execute SQL via docker compose exec.
Returns:
Tuple of (return_code, stdout, stderr)
"""
compose_dir = find_compose_dir()
cmd = [
"docker",
"compose",
"exec",
"-T", # Disable pseudo-TTY allocation
"db",
"mariadb",
f"-u{user}",
f"-p{password}",
database,
"-e",
sql,
]
if vertical:
cmd.insert(-2, "-E") # Vertical output format
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=compose_dir,
)
return result.returncode, result.stdout, result.stderr
def main() -> int:
parser = argparse.ArgumentParser(
description="Execute SQL queries against the Nextcloud MariaDB database",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s "SELECT COUNT(*) FROM oc_notes"
%(prog)s "SELECT id, name FROM oc_oidc_clients"
%(prog)s -E "SELECT * FROM oc_users LIMIT 1"
%(prog)s --user nextcloud --password nextcloud "SHOW TABLES"
""",
)
parser.add_argument("sql", help="SQL statement to execute")
parser.add_argument(
"-u", "--user", default="root", help="Database user (default: root)"
)
parser.add_argument(
"-p",
"--password",
default="password",
help="Database password (default: password)",
)
parser.add_argument(
"-d",
"--database",
default="nextcloud",
help="Database name (default: nextcloud)",
)
parser.add_argument(
"-E",
"--vertical",
action="store_true",
help="Print output vertically (one column per line)",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Request JSON output (if supported)",
)
args = parser.parse_args()
returncode, stdout, stderr = run_query(
sql=args.sql,
user=args.user,
password=args.password,
database=args.database,
vertical=args.vertical,
json_output=args.json_output,
)
if stdout:
print(stdout, end="")
if stderr:
# Filter out the password warning
filtered_stderr = "\n".join(
line
for line in stderr.splitlines()
if "Using a password on the command line interface can be insecure"
not in line
)
if filtered_stderr:
print(filtered_stderr, file=sys.stderr)
return returncode
if __name__ == "__main__":
sys.exit(main())
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
SQLite database query helper for MCP service development.
Wraps `docker compose exec <service> sqlite3` to execute SQL statements
against the token storage database in any MCP service container.
Usage:
./scripts/sqlitequery.py ".tables"
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
./scripts/sqlitequery.py -s keycloak --headers "SELECT * FROM oauth_clients"
./scripts/sqlitequery.py --json "SELECT * FROM audit_logs LIMIT 5"
"""
import argparse
import subprocess
import sys
from pathlib import Path
# Service name aliases for convenience
SERVICE_ALIASES = {
"mcp": "mcp",
"oauth": "mcp-oauth",
"mcp-oauth": "mcp-oauth",
"keycloak": "mcp-keycloak",
"mcp-keycloak": "mcp-keycloak",
"basic": "mcp-multi-user-basic",
"multi-user-basic": "mcp-multi-user-basic",
"mcp-multi-user-basic": "mcp-multi-user-basic",
}
def find_compose_dir() -> Path:
"""Find the directory containing docker-compose.yml."""
current = Path(__file__).resolve().parent
while current != current.parent:
if (current / "docker-compose.yml").exists():
return current
if (current / "compose.yml").exists():
return current
current = current.parent
# Default to script's parent directory
return Path(__file__).resolve().parent.parent
def resolve_service(service: str) -> str:
"""Resolve service alias to container name."""
resolved = SERVICE_ALIASES.get(service.lower())
if resolved is None:
# Not a known alias, use as-is (might be a custom service)
return service
return resolved
def run_query(
sql: str,
service: str = "mcp",
database: str = "/app/data/tokens.db",
headers: bool = False,
json_output: bool = False,
column_mode: bool = False,
) -> tuple[int, str, str]:
"""
Execute SQL via docker compose exec.
Returns:
Tuple of (return_code, stdout, stderr)
"""
compose_dir = find_compose_dir()
container = resolve_service(service)
# Build sqlite3 command with options
sqlite_args = []
# Set output mode
if json_output:
sqlite_args.extend(["-json"])
elif column_mode:
sqlite_args.extend(["-column"])
# Enable headers
if headers or column_mode:
sqlite_args.extend(["-header"])
cmd = [
"docker",
"compose",
"exec",
"-T", # Disable pseudo-TTY allocation
container,
"sqlite3",
*sqlite_args,
database,
sql,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=compose_dir,
)
return result.returncode, result.stdout, result.stderr
def main() -> int:
parser = argparse.ArgumentParser(
description="Execute SQL queries against SQLite databases in MCP service containers",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Services:
mcp Single-user BasicAuth mode (default)
oauth Nextcloud OAuth mode (mcp-oauth)
keycloak Keycloak OAuth mode (mcp-keycloak)
basic Multi-user BasicAuth mode (mcp-multi-user-basic)
Examples:
%(prog)s ".tables"
%(prog)s -s oauth "SELECT user_id FROM refresh_tokens"
%(prog)s -s keycloak ".schema oauth_clients"
%(prog)s --headers "SELECT * FROM audit_logs LIMIT 5"
%(prog)s --json "SELECT * FROM oauth_sessions"
""",
)
parser.add_argument("sql", help="SQL statement or SQLite command to execute")
parser.add_argument(
"-s",
"--service",
default="mcp",
help="Target service (mcp, oauth, keycloak, basic) (default: mcp)",
)
parser.add_argument(
"-d",
"--database",
default="/app/data/tokens.db",
help="Database path inside container (default: /app/data/tokens.db)",
)
parser.add_argument(
"--headers",
action="store_true",
help="Show column headers",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output in JSON format",
)
parser.add_argument(
"--column",
action="store_true",
dest="column_mode",
help="Output in column format with headers",
)
args = parser.parse_args()
returncode, stdout, stderr = run_query(
sql=args.sql,
service=args.service,
database=args.database,
headers=args.headers,
json_output=args.json_output,
column_mode=args.column_mode,
)
if stdout:
print(stdout, end="")
if stderr:
print(stderr, file=sys.stderr)
return returncode
if __name__ == "__main__":
sys.exit(main())
+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"
+41 -47
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")
@@ -2353,32 +2351,41 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
except Exception as e:
logger.warning(f"Error creating editors group (may already exist): {e}")
# Create each test user
# Create each test user (idempotent - check if exists first)
for username, config in test_user_configs.items():
# Check if user already exists
user_exists = False
try:
await nc_client.users.create_user(
userid=username,
password=config["password"],
display_name=config["display_name"],
email=config["email"],
)
logger.info(f"Created test user: {username}")
created_users.append(username)
await nc_client.users.get_user_details(username)
user_exists = True
logger.info(f"Test user {username} already exists, skipping creation")
except Exception:
# User doesn't exist, proceed with creation
pass
# Add user to groups if specified
for group in config["groups"]:
try:
await nc_client.users.add_user_to_group(username, group)
logger.info(f"Added {username} to group {group}")
except Exception as e:
logger.warning(f"Error adding {username} to group {group}: {e}")
if not user_exists:
try:
await nc_client.users.create_user(
userid=username,
password=config["password"],
display_name=config["display_name"],
email=config["email"],
)
logger.info(f"Created test user: {username}")
created_users.append(username) # Only track users WE created
except Exception as e:
# User might already exist, that's okay
logger.warning(
f"Could not create user {username} (may already exist): {e}"
)
created_users.append(username) # Add to list anyway for cleanup
# Add user to groups if specified
for group in config["groups"]:
try:
await nc_client.users.add_user_to_group(username, group)
logger.info(f"Added {username} to group {group}")
except Exception as e:
logger.warning(
f"Error adding {username} to group {group}: {e}"
)
except Exception as e:
logger.warning(f"Could not create user {username}: {e}")
logger.info(f"Test users setup complete: {created_users}")
yield test_user_configs
@@ -2417,9 +2424,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 +2564,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 +2714,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 +2749,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 +2883,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 +3248,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,151 +1,262 @@
"""Integration tests for app password provisioning via Astrolabe.
"""Integration tests for app password provisioning via management API.
Tests the complete flow:
1. User stores app password via Astrolabe API
2. MCP server retrieves it via OAuth client credentials
3. Background sync uses it to access Nextcloud
Tests the complete flow for multi-user BasicAuth mode:
1. User stores app password via management API endpoint
2. MCP server stores it locally (encrypted)
3. Background sync uses locally stored password to access Nextcloud
These tests verify that BasicAuth and OAuth are completely separate concerns
with no fallback between them.
"""
import tempfile
from pathlib import Path
import pytest
from httpx import BasicAuth
from cryptography.fernet import Fernet
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.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError,
get_user_client,
get_user_client_basic_auth,
get_user_client_oauth,
)
@pytest.fixture
def encryption_key():
"""Generate a test encryption key."""
return Fernet.generate_key().decode()
@pytest.fixture
async def temp_storage(encryption_key):
"""Create temporary storage instance with encryption for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_provisioning.db"
storage = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage.initialize()
yield storage
@pytest.mark.integration
async def test_astrolabe_client_initialization():
"""Test AstrolabeClient can be instantiated."""
client = AstrolabeClient(
async def test_basic_auth_mode_uses_local_storage(temp_storage, mocker):
"""Test that BasicAuth mode uses locally stored app passwords.
In multi-user BasicAuth mode, app passwords are stored locally
in the MCP server's database after being provisioned via the API.
"""
# Store an app password in local storage
await temp_storage.store_app_password("test_user", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
# Call get_user_client_basic_auth with local storage
client = await get_user_client_basic_auth(
user_id="test_user",
nextcloud_host="http://localhost:8080",
client_id="test-client",
client_secret="test-secret",
storage=temp_storage,
)
# Verify client was created with correct credentials
assert client is not None
assert client.nextcloud_host == "http://localhost:8080"
assert client.client_id == "test-client"
assert client.client_secret == "test-secret"
assert client._token_cache is None
assert client.username == "test_user"
@pytest.mark.integration
async def test_astrolabe_client_get_access_token_requires_oidc():
"""Test that getting access token requires OIDC discovery endpoint."""
client = AstrolabeClient(
nextcloud_host="http://localhost:8080",
client_id="test-client",
client_secret="test-secret",
)
async def test_basic_auth_mode_raises_error_without_app_password(temp_storage):
"""Test that BasicAuth mode raises NotProvisionedError if no app password.
# This will fail without proper OIDC setup, which is expected
# The test verifies the client follows the OAuth client credentials flow
try:
token = await client.get_access_token()
# If we get here, OIDC is configured
assert token is not None
except Exception as e:
# Expected if OIDC not fully configured for test client
# 400/401/403/404 all indicate the flow is working but credentials are invalid
assert any(code in str(e) for code in ["400", "401", "403", "404"])
There is NO fallback to OAuth - if no app password, user must provision one.
"""
# Don't store any app password
@pytest.mark.integration
async def test_get_user_app_password_returns_none_for_unconfigured_user():
"""Test that get_user_app_password returns None for users without app passwords."""
# This requires valid OAuth client credentials
settings = get_settings()
if not settings.oidc_client_id or not settings.oidc_client_secret:
pytest.skip("OAuth client credentials not configured")
client = AstrolabeClient(
nextcloud_host=settings.nextcloud_host or "http://localhost:8080",
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
)
# Try to get app password for a user that hasn't provisioned one
try:
app_password = await client.get_user_app_password("nonexistent_user")
# Should return None for unconfigured user (404 response)
assert app_password is None
except Exception as e:
# May fail with auth error if OAuth not fully configured
assert any(code in str(e) for code in ["400", "401", "403", "404"])
@pytest.mark.integration
async def test_dual_credential_support_in_background_sync(mocker):
"""Test that background sync tries app password first, then refresh token."""
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
# Mock AstrolabeClient to return an app password
mock_astrolabe = mocker.AsyncMock()
mock_astrolabe.get_user_app_password.return_value = "test-app-password-12345"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
return_value=mock_astrolabe,
)
# Mock TokenBrokerService (shouldn't be called if app password works)
mock_token_broker = mocker.MagicMock(spec=TokenBrokerService)
# Call get_user_client - should use app password
try:
_client = await get_user_client(
# Call get_user_client_basic_auth - should raise NotProvisionedError
with pytest.raises(NotProvisionedError) as exc_info:
await get_user_client_basic_auth(
user_id="test_user",
token_broker=mock_token_broker,
nextcloud_host="http://localhost:8080",
storage=temp_storage,
)
# 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 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_background_sync_falls_back_to_refresh_token(mocker):
"""Test that background sync falls back to refresh token if no app password."""
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
# Mock AstrolabeClient to return None (no app password)
mock_astrolabe = mocker.AsyncMock()
mock_astrolabe.get_user_app_password.return_value = None
async def test_get_user_client_dispatches_to_basic_auth(temp_storage, mocker):
"""Test that get_user_client dispatches to BasicAuth mode correctly."""
# Store an app password
await temp_storage.store_app_password("alice", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
# Mock RefreshTokenStorage.from_env at the source module
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
return_value=mock_astrolabe,
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
return_value=temp_storage,
)
# Also mock initialize since from_env returns an uninitialized instance
mocker.patch.object(temp_storage, "initialize", return_value=None)
# Call get_user_client in BasicAuth mode
client = await get_user_client(
user_id="alice",
token_broker=None, # No token broker needed for BasicAuth mode
nextcloud_host="http://localhost:8080",
use_basic_auth=True,
)
# Verify client was created successfully
assert client is not None
assert client.username == "alice"
@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
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_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
)
@pytest.mark.integration
async def test_multiple_users_basic_auth_mode(temp_storage, mocker):
"""Test that multiple users can be provisioned independently."""
# Store app passwords for multiple users
users = {
"alice": "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa",
"bob": "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb",
"charlie": "ccccc-ccccc-ccccc-ccccc-ccccc",
}
for user_id, password in users.items():
await temp_storage.store_app_password(user_id, password)
# Verify each user can get a client
for user_id in users.keys():
client = await get_user_client_basic_auth(
user_id=user_id,
nextcloud_host="http://localhost:8080",
storage=temp_storage,
)
assert client is not None
assert client.username == user_id
@pytest.mark.integration
async def test_get_all_provisioned_users(temp_storage):
"""Test that we can list all provisioned users for BasicAuth mode."""
# Store app passwords for multiple users
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
# Get all provisioned users
user_ids = await temp_storage.get_all_app_password_user_ids()
assert len(user_ids) == 2
assert "alice" in user_ids
assert "bob" in user_ids
@pytest.mark.integration
async def test_revoke_app_password(temp_storage):
"""Test that deleting app password revokes background access."""
# Provision user
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
# Verify user is provisioned
user_ids = await temp_storage.get_all_app_password_user_ids()
assert "alice" in user_ids
# Revoke access
deleted = await temp_storage.delete_app_password("alice")
assert deleted is True
# Verify user is no longer provisioned
user_ids = await temp_storage.get_all_app_password_user_ids()
assert "alice" not in user_ids
# Verify get_user_client now raises NotProvisionedError
with pytest.raises(NotProvisionedError):
await get_user_client_basic_auth(
user_id="alice",
nextcloud_host="http://localhost:8080",
storage=temp_storage,
)
@@ -13,6 +13,8 @@ app password entry → background sync activation → database verification.
"""
import logging
import re
import subprocess
import anyio
import pytest
@@ -41,8 +43,19 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
# Submit form
await page.click('button[type="submit"]')
# Submit form - use force=True to bypass stability check (CSS transitions)
submit_button = page.locator('button[type="submit"]')
try:
await submit_button.click(force=True, timeout=10000)
except Exception:
# Fallback: JavaScript click
logger.info("Using JavaScript click for login button...")
await page.evaluate(
"""
const btn = document.querySelector('button[type="submit"]');
if (btn) btn.click();
"""
)
await page.wait_for_load_state("networkidle", timeout=30000)
# Verify logged in (should redirect away from login page)
@@ -73,6 +86,289 @@ async def navigate_to_astrolabe_settings(page: Page):
logger.info("✓ Successfully loaded Astrolabe settings page")
async def authorize_search_access(page: Page, username: str) -> bool:
"""Complete Step 1: OAuth Authorization for Astrolabe.
Handles the OAuth flow:
1. Check if already authorized (Step 1 shows "Complete")
2. Click "Authorize" link
3. Handle Nextcloud OIDC consent screen
4. Wait for redirect back to Astrolabe settings
5. Verify "Complete" badge appears on Step 1
Args:
page: Playwright page instance (must be on Astrolabe settings page)
username: Username for logging
Returns:
True if authorization completed successfully
"""
nextcloud_url = "http://localhost:8080"
logger.info(f"Authorizing search access (Step 1) for {username}...")
# Check if already on Astrolabe settings page, if not navigate there
if "/settings/user/astrolabe" not in page.url:
await navigate_to_astrolabe_settings(page)
# Wait for page to fully render
await anyio.sleep(1)
# Check if already authorized (either "Active" badge or Step 1 "Complete" badge)
try:
# Check for "Active" badge (fully configured state)
active_badge = page.get_by_text("Active", exact=True)
if await active_badge.count() > 0 and await active_badge.is_visible():
logger.info(f"✓ Already fully authorized for {username} (Active badge)")
return True
except Exception:
pass
try:
step1_section = page.locator('h4:has-text("Step 1")')
if await step1_section.count() > 0:
# Look for "Complete" text in the Step 1 section's parent
step1_parent = step1_section.locator("..")
complete_badge = step1_parent.get_by_text("Complete", exact=True)
if await complete_badge.count() > 0 and await complete_badge.is_visible():
logger.info(f"✓ Step 1 already complete for {username}")
return True
except Exception:
pass
# Find and click the "Authorize" button
authorize_button = page.locator('a.button.primary:has-text("Authorize")')
try:
await authorize_button.wait_for(timeout=5000, state="visible")
logger.info(f"Found Authorize button for {username}")
except Exception:
# Take screenshot for debugging
screenshot_path = f"/tmp/astrolabe_no_authorize_button_{username}.png"
await page.screenshot(path=screenshot_path)
logger.error(
f"Could not find Authorize button for {username}. Screenshot: {screenshot_path}"
)
raise ValueError(f"Authorize button not found for {username}")
# Click the Authorize button - this will redirect to OAuth provider
# Use force=True to bypass stability check which can timeout due to CSS transitions
await authorize_button.click(force=True)
logger.info(f"Clicked Authorize button for {username}")
# Wait for OAuth redirect to complete
await page.wait_for_load_state("networkidle", timeout=30000)
logger.info(f"After networkidle, current URL: {page.url}")
# Take screenshot to see current state
await page.screenshot(path=f"/tmp/astrolabe_after_authorize_{username}.png")
logger.info(f"Screenshot saved: /tmp/astrolabe_after_authorize_{username}.png")
# Handle OIDC consent screen if present
consent_handled = await _handle_oauth_consent_screen(page, username)
if consent_handled:
logger.info(f"✓ OAuth consent granted for {username}")
else:
logger.info(
f"No consent screen required for {username} (may be previously authorized)"
)
# Wait for redirect back to Astrolabe settings
# The OAuth callback will redirect back to /settings/user/astrolabe
try:
await page.wait_for_url(
f"**{nextcloud_url}/settings/user/astrolabe**", timeout=30000
)
logger.info(f"Redirected back to Astrolabe settings for {username}")
except Exception:
# Check if we're already on settings page
if "/settings/user/astrolabe" not in page.url:
logger.warning(
f"Not redirected to Astrolabe settings, current URL: {page.url}"
)
# Navigate manually
await page.goto(
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
)
# Wait for page to reload and render
await anyio.sleep(2)
# Verify authorization completed - check for various success indicators
# When fully configured, shows "Active" badge; when only Step 1 done, shows "Complete"
try:
# First check if "Active" badge is shown (fully configured state)
active_badge = page.get_by_text("Active", exact=True)
if await active_badge.count() > 0 and await active_badge.is_visible():
logger.info(f"✓ OAuth authorization complete for {username} (Active badge)")
return True
except Exception:
pass
try:
# Check for Step 1 "Complete" badge (partial configuration)
step1_section = page.locator('h4:has-text("Step 1")')
if await step1_section.count() > 0:
step1_parent = step1_section.locator("..")
complete_badge = step1_parent.get_by_text("Complete", exact=True)
await complete_badge.wait_for(timeout=5000, state="visible")
logger.info(f"✓ Step 1 OAuth authorization complete for {username}")
return True
except Exception:
pass
# Neither badge found - authorization failed
screenshot_path = f"/tmp/astrolabe_step1_not_complete_{username}.png"
await page.screenshot(path=screenshot_path)
logger.error(
f"Authorization badge not visible for {username}. Screenshot: {screenshot_path}"
)
raise ValueError(f"OAuth authorization did not complete for {username}")
async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
"""Handle the OIDC consent screen during OAuth flow.
Reuses the proven pattern from tests/conftest.py.
Args:
page: Playwright page instance
username: Username for logging
Returns:
True if consent was handled, False if no consent screen was found
"""
try:
logger.info(f"Checking for consent screen at URL: {page.url}")
# Check if consent screen is present - try multiple selectors
# The consent screen may be #oidc-consent or use a different format
consent_div = await page.query_selector("#oidc-consent")
if consent_div:
logger.info(f"Consent screen detected via #oidc-consent for {username}")
# Get consent screen data attributes for logging
client_name = await consent_div.get_attribute("data-client-name")
scopes_attr = await consent_div.get_attribute("data-scopes")
logger.info(f" Client: {client_name}")
logger.info(f" Requested scopes: {scopes_attr}")
else:
# Check for Allow button directly (different consent screen format)
allow_button = page.locator('button:has-text("Allow")')
if await allow_button.count() > 0:
logger.info(f"Consent screen detected via Allow button for {username}")
else:
logger.info(f"No consent screen found for {username} at {page.url}")
await page.screenshot(path=f"/tmp/no_consent_screen_{username}.png")
logger.info(f"Screenshot: /tmp/no_consent_screen_{username}.png")
return False
# Wait for Vue.js to render the Allow button
try:
await page.wait_for_selector('button:has-text("Allow")', timeout=10000)
logger.info(" Allow button rendered by Vue.js")
except Exception as e:
screenshot_path = f"/tmp/consent_no_allow_button_{username}.png"
await page.screenshot(path=screenshot_path)
logger.error(f" Timeout waiting for Allow button: {e}")
raise
# Check all scope checkboxes
scope_checkboxes = await page.query_selector_all('input[type="checkbox"]')
if scope_checkboxes:
logger.info(f" Found {len(scope_checkboxes)} scope checkboxes")
for i, checkbox in enumerate(scope_checkboxes):
is_checked = await checkbox.is_checked()
is_disabled = await checkbox.is_disabled()
if not is_checked and not is_disabled:
await checkbox.check()
logger.info(f" ✓ Checked scope checkbox {i + 1}")
# Click the Allow button using JavaScript (handles viewport issues)
allow_button_locator = page.locator('button:has-text("Allow")')
# Debug: take screenshot before clicking Allow
await page.screenshot(path=f"/tmp/consent_before_allow_{username}.png")
logger.info(
f" Screenshot before Allow: /tmp/consent_before_allow_{username}.png"
)
button_count = await allow_button_locator.count()
logger.info(f" Found {button_count} Allow button(s)")
if button_count > 0:
current_url = page.url
logger.info(f" Current URL: {current_url}")
logger.info(f" Clicking Allow button for {username}...")
# Use JavaScript click to handle consent buttons (proven pattern from conftest.py)
# This is more reliable than Playwright's click for Vue.js rendered buttons
await page.evaluate(
"""
const buttons = document.querySelectorAll('button');
for (const btn of buttons) {
if (btn.textContent.trim() === 'Allow') {
btn.click();
break;
}
}
"""
)
# Wait for URL to change (Vue.js uses window.location.href after fetch)
# networkidle doesn't detect fetch-based redirects
try:
await page.wait_for_url(
lambda url: url != current_url,
timeout=30000,
)
logger.info(f" URL changed to: {page.url}")
except Exception as wait_error:
# If URL didn't change, check console for errors
logger.warning(f" URL didn't change after click: {wait_error}")
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
# Try alternative: manually POST consent and navigate
logger.info(" Trying manual consent submission...")
try:
redirect_url = await page.evaluate(
"""
async () => {
const selectedScopes = Array.from(document.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => cb.value).join(' ');
const response = await fetch('/index.php/apps/oidc/consent/grant', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'requesttoken': OC.requestToken,
},
body: 'scopes=' + encodeURIComponent(selectedScopes),
redirect: 'follow',
});
return response.url || '/index.php/apps/oidc/authorize';
}
"""
)
logger.info(f" Manual consent returned URL: {redirect_url}")
await page.goto(redirect_url, wait_until="networkidle")
except Exception as manual_error:
logger.error(f" Manual consent also failed: {manual_error}")
raise
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
logger.info(f" Consent granted for {username}")
return True
else:
logger.error(f" Allow button not found for {username}")
return False
except Exception as e:
logger.error(f"Error handling consent screen for {username}: {e}")
raise
async def generate_app_password(
page: Page, username: str, app_name: str = "Astrolabe Background Sync"
) -> str:
@@ -103,16 +399,32 @@ async def generate_app_password(
await anyio.sleep(1.0)
logger.info("Waited for Vue.js to process input and enable button")
# Click the create button
# Click the create button - use force=True to bypass stability check (CSS transitions)
create_button = page.locator(
'button[type="submit"]:has-text("Create new app password")'
)
await create_button.click()
try:
await create_button.click(force=True, timeout=10000)
except Exception:
# Fallback: JavaScript click
logger.info("Using JavaScript click for create button...")
await page.evaluate(
"""
const btn = document.querySelector('button[type="submit"]');
if (btn) btn.click();
"""
)
logger.info("Clicked create app password button")
# Wait for app password to be generated and displayed in the dialog
await anyio.sleep(3) # Give it more time to generate and display
# Debug screenshot after clicking create
await page.screenshot(path=f"/tmp/app_password_after_create_{username}.png")
logger.info(
f"Screenshot after create: /tmp/app_password_after_create_{username}.png"
)
# Find the Login input field which should have the username value
# Then find the Password input field which is in the same form
app_password = None
@@ -151,7 +463,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}$",
@@ -171,11 +482,11 @@ async def generate_app_password(
f"✓ Generated app password for {username}: {app_password[:10]}... (validated)"
)
# Close the dialog by clicking the Close button
close_button = page.get_by_role("button", name="Close")
await close_button.click()
# Close dialog with Escape key (bypasses CSS layout issues with h2 intercepting clicks)
logger.info("Closing app password dialog with Escape key...")
await page.keyboard.press("Escape")
await anyio.sleep(0.5) # Wait for dialog close animation
logger.info("Closed app password dialog")
await anyio.sleep(0.5)
return app_password
@@ -225,9 +536,9 @@ async def enable_background_sync_via_app_password(
# Wait for page to load
await anyio.sleep(1)
# Check if already active (look for "Active" text in the Background Sync Access section)
# Check if already complete (look for Step 2 "Complete" badge or overall "Active" state)
try:
# The "Active" badge appears as a <span> with text "Active"
# First check for overall "Active" badge (both steps complete)
active_text = page.get_by_text("Active", exact=True)
if await active_text.is_visible(timeout=2000):
logger.info(f"✓ Background sync already active for {username}")
@@ -235,6 +546,18 @@ async def enable_background_sync_via_app_password(
except Exception:
pass
try:
# Check for Step 2 "Complete" badge (app password already set)
step2_section = page.locator('h4:has-text("Step 2")')
if await step2_section.count() > 0:
step2_parent = step2_section.locator("..")
complete_badge = step2_parent.get_by_text("Complete", exact=True)
if await complete_badge.count() > 0 and await complete_badge.is_visible():
logger.info(f"✓ Step 2 (app password) already complete for {username}")
return True
except Exception:
pass
# Find the app password input field using the placeholder text
# Based on manual testing: textbox with placeholder "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
@@ -318,21 +641,120 @@ async def enable_background_sync_via_app_password(
except Exception:
pass
# Verify "Active" text appears after reload
# Verify Step 2 "Complete" badge or overall "Active" badge appears after reload
try:
# First try to find "Active" badge (both steps complete)
active_text = page.get_by_text("Active", exact=True)
if await active_text.count() > 0:
await active_text.wait_for(timeout=5000, state="visible")
logger.info(
f"✓ Background sync enabled for {username} - Active badge visible"
)
return True
except Exception:
pass
try:
# Check for Step 2 "Complete" badge
step2_section = page.locator('h4:has-text("Step 2")')
if await step2_section.count() > 0:
step2_parent = step2_section.locator("..")
complete_badge = step2_parent.get_by_text("Complete", exact=True)
await complete_badge.wait_for(timeout=5000, state="visible")
logger.info(
f"✓ Step 2 (app password) enabled for {username} - Complete badge visible"
)
return True
except Exception:
pass
# If neither badge found, raise error
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
await page.screenshot(path=screenshot_path)
logger.error(
f"Neither Active nor Complete badge appeared for {username}. "
f"Screenshot: {screenshot_path}"
)
raise ValueError(f"Background sync setup did not complete for {username}")
async def complete_astrolabe_authorization(
page: Page, username: str, password: str
) -> dict:
"""Complete full Astrolabe two-step authorization.
Performs the complete authorization flow:
1. Navigate to Astrolabe settings
2. OAuth authorization (Step 1) if needed
3. Generate app password in Security settings
4. App password entry (Step 2) if needed
Args:
page: Playwright page instance (must be logged in)
username: Nextcloud username
password: Nextcloud password (for reference, not used directly)
Returns:
Dict with {"step1": bool, "step2": bool, "app_password": str | None}
"""
logger.info(f"Starting full Astrolabe authorization for {username}...")
result = {"step1": False, "step2": False, "app_password": None}
# Navigate to Astrolabe settings
await navigate_to_astrolabe_settings(page)
# Step 1: OAuth authorization
try:
result["step1"] = await authorize_search_access(page, username)
logger.info(f"✓ Step 1 complete for {username}")
except Exception as e:
logger.error(f"Step 1 failed for {username}: {e}")
raise
# Navigate back to settings if needed (OAuth might have redirected elsewhere)
if "/settings/user/astrolabe" not in page.url:
await navigate_to_astrolabe_settings(page)
# Check if Step 2 is already complete
try:
step2_section = page.locator('h4:has-text("Step 2")')
if await step2_section.count() > 0:
step2_parent = step2_section.locator("..")
complete_badge = step2_parent.get_by_text("Complete", exact=True)
if await complete_badge.count() > 0 and await complete_badge.is_visible():
logger.info(f"✓ Step 2 already complete for {username}")
result["step2"] = True
return result
except Exception:
pass
# Also check for overall "Active" badge
try:
active_text = page.get_by_text("Active", exact=True)
await active_text.wait_for(timeout=5000, state="visible")
logger.info(f"Background sync enabled for {username} - Active badge visible")
return True
if await active_text.count() > 0 and await active_text.is_visible():
logger.info(f"Authorization already fully active for {username}")
result["step2"] = True
return result
except Exception:
# Take screenshot for debugging
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
await page.screenshot(path=screenshot_path)
logger.error(
f"Active badge did not appear for {username}. Screenshot: {screenshot_path}"
pass
# Step 2: Generate app password and enter it
app_password = await generate_app_password(page, username)
result["app_password"] = app_password
try:
result["step2"] = await enable_background_sync_via_app_password(
page, username, app_password
)
logger.info(f"✓ Step 2 complete for {username}")
except Exception as e:
logger.error(f"Step 2 failed for {username}: {e}")
raise
logger.info(f"✓ Full Astrolabe authorization complete for {username}")
return result
async def verify_app_password_created(username: str) -> bool:
"""Verify that background sync app password was stored for the user.
@@ -350,7 +772,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 +980,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()
@@ -0,0 +1,371 @@
"""Integration test for Astrolabe Plotly 3D visualization with multi-user BasicAuth mode.
This test verifies that:
1. User can provision background sync access via app password
2. Content created via MCP tools is indexed by vector sync
3. Semantic search via Astrolabe UI returns results
4. Plotly 3D visualization container renders correctly
Requires:
- docker-compose up -d app db mcp-multi-user-basic
- ENABLE_SEMANTIC_SEARCH=true on the mcp-multi-user-basic container
"""
import base64
import json
import logging
import re
import uuid
import anyio
import pytest
from playwright.async_api import Page
# Import helper functions from existing test
from tests.conftest import create_mcp_client_session
from tests.integration.test_astrolabe_multi_user_background_sync import (
complete_astrolabe_authorization,
login_to_nextcloud,
)
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def wait_for_vector_sync(
mcp_client, initial_indexed_count: int, timeout_seconds: int = 60
) -> tuple[bool, dict | None]:
"""Wait for vector sync to index new content.
Args:
mcp_client: MCP client session
initial_indexed_count: Initial indexed document count before creating content
timeout_seconds: Maximum time to wait for sync
Returns:
Tuple of (success, status_data)
"""
wait_interval = 2
waited = 0
status_data = None
while waited < timeout_seconds:
sync_status = await mcp_client.call_tool("nc_get_vector_sync_status", {})
if sync_status.isError:
logger.warning(f"Vector sync status error: {sync_status}")
return False, None
status_data = json.loads(sync_status.content[0].text)
indexed_count = status_data.get("indexed_count", 0)
pending_count = status_data.get("pending_count", 1)
logger.info(
f"Sync status at {waited}s: indexed={indexed_count}, "
f"pending={pending_count}, status={status_data.get('status')}"
)
if indexed_count > initial_indexed_count and pending_count == 0:
logger.info(
f"✓ Sync complete: {indexed_count} documents indexed "
f"(was {initial_indexed_count})"
)
return True, status_data
await anyio.sleep(wait_interval)
waited += wait_interval
return False, status_data
async def navigate_to_astrolabe_main(page: Page):
"""Navigate to Astrolabe main app page (Semantic Search section).
Args:
page: Playwright page instance (must be authenticated)
"""
nextcloud_url = "http://localhost:8080"
logger.info("Navigating to Astrolabe main app...")
await page.goto(f"{nextcloud_url}/apps/astrolabe", wait_until="networkidle")
# Wait for the app to load
await anyio.sleep(1)
logger.info("✓ Successfully loaded Astrolabe main app")
@pytest.mark.integration
@pytest.mark.oauth
@pytest.mark.timeout(
300
) # 5 minutes - this test involves OAuth, app password, and vector sync
async def test_astrolabe_plotly_visualization_with_basic_auth(
browser,
test_users_setup,
configure_astrolabe_for_mcp_server,
):
"""Test Plotly 3D visualization in Astrolabe with multi-user BasicAuth mode.
This test:
1. Configures Astrolabe for the mcp-multi-user-basic service
2. Provisions background sync access for alice via app password
3. Creates a note with unique searchable content (as alice)
4. Waits for vector sync to index the note
5. Performs semantic search in Astrolabe UI
6. Verifies the Plotly visualization renders and results are displayed
"""
# Phase 1: Configure Astrolabe for mcp-multi-user-basic
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
mcp_server_public_url="http://localhost:8003",
)
username = "alice"
password = test_users_setup[username]["password"]
note_id = None
unique_term = None
# Create MCP client with alice's credentials for the multi-user BasicAuth server
credentials = base64.b64encode(f"{username}:{password}".encode()).decode("utf-8")
auth_header = f"Basic {credentials}"
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
# Phase 2: Complete full Astrolabe authorization (OAuth + app password)
await login_to_nextcloud(page, username, password)
auth_result = await complete_astrolabe_authorization(page, username, password)
logger.info(f"Authorization result: {auth_result}")
# Create MCP client session as alice - all MCP operations inside this block
async for alice_mcp_client in create_mcp_client_session(
url="http://localhost:8003/mcp",
headers={"Authorization": auth_header},
client_name="Alice BasicAuth MCP",
):
# Phase 3: Get initial indexed count
initial_sync = await alice_mcp_client.call_tool(
"nc_get_vector_sync_status", {}
)
if initial_sync.isError:
pytest.skip("Vector sync not enabled on mcp-multi-user-basic")
initial_data = json.loads(initial_sync.content[0].text)
initial_count = initial_data.get("indexed_count", 0)
logger.info(f"Initial indexed count: {initial_count}")
# Create note with unique searchable term
unique_term = f"plotly_viz_test_{uuid.uuid4().hex[:8]}"
note_response = await alice_mcp_client.call_tool(
"nc_notes_create_note",
{
"title": f"Visualization Test Note {unique_term}",
"content": f"""# Testing Plotly Visualization
This note contains the unique term: {unique_term}
It is used to test the 3D vector space visualization in the Astrolabe app.
The visualization should show this document as a point in PCA-reduced space.
## Key Features
- Semantic search with embeddings
- PCA dimension reduction to 3D
- Interactive Plotly scatter3d plot
""",
"category": "Test",
},
)
if note_response.isError:
pytest.fail(f"Failed to create test note: {note_response}")
note_data = json.loads(note_response.content[0].text)
note_id = note_data.get("id")
logger.info(f"Created test note ID: {note_id}")
# Phase 4: Wait for vector indexing
sync_complete, status = await wait_for_vector_sync(
alice_mcp_client, initial_count, timeout_seconds=90
)
assert sync_complete, f"Vector sync did not complete in time: {status}"
# Phase 5: Navigate to Astrolabe and perform search
await navigate_to_astrolabe_main(page)
# Fill search query - find the Astrolabe search input specifically
# The NcTextField component wraps the input in a div with class mcp-search-input
search_input = page.locator(".mcp-search-input input")
await search_input.wait_for(timeout=10000, state="visible")
await search_input.fill(unique_term)
logger.info(f"Entered search query: {unique_term}")
# Trigger search by pressing Enter on the input field
# This is wired to performSearch via @keyup.enter in the Vue component
await search_input.press("Enter")
logger.info("Pressed Enter to trigger search")
# Wait for loading to complete - watch for loading indicator to disappear
loading_indicator = page.locator(".mcp-loading")
try:
# If loading indicator appears, wait for it to disappear
if await loading_indicator.count() > 0:
await loading_indicator.wait_for(state="hidden", timeout=30000)
logger.info("Loading completed")
except Exception:
# Loading might be too fast to catch
pass
# Brief wait for UI to settle
await anyio.sleep(1)
# Take diagnostic screenshot
await page.screenshot(path="/tmp/astrolabe_search_after_click.png")
logger.info(
"Took diagnostic screenshot: /tmp/astrolabe_search_after_click.png"
)
# Wait for search results using text-based detection
# This is more reliable than class-based selectors
# The UI shows "N results" when search completes successfully
results_text_pattern = page.get_by_text(re.compile(r"\d+ results?"))
no_results_text = page.get_by_text("No results found")
error_note = page.locator(".mcp-error")
# Wait for one of: results count, no results message, or error
try:
# Poll for results or error states (don't rely on Nextcloud core CSS classes)
found_state = False
for attempt in range(60): # 60 attempts, 500ms each = 30s total
if await error_note.count() > 0:
error_text = await error_note.text_content()
logger.error(f"Search error: {error_text}")
pytest.fail(f"Search failed with error: {error_text}")
if await no_results_text.count() > 0:
logger.warning(
"No results found - vector sync may not have completed"
)
await page.screenshot(path="/tmp/astrolabe_no_results.png")
pytest.fail(
f"Search returned no results for '{unique_term}'. "
"Check if vector sync completed for alice's content."
)
if await results_text_pattern.count() > 0:
results_text = await results_text_pattern.first.text_content()
logger.info(f"Found results: {results_text}")
found_state = True
break
if attempt % 10 == 0:
logger.info(
f"Waiting for results... (attempt {attempt + 1}/60)"
)
await anyio.sleep(0.5)
if not found_state:
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
page_content = await page.content()
logger.error(f"Search state not resolved. Page URL: {page.url}")
logger.error(f"Page content snippet: {page_content[:2000]}")
raise AssertionError("Search did not complete within timeout")
except AssertionError:
raise # Re-raise AssertionError as-is
except Exception as e:
# Take another screenshot and get page content for debugging
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
page_content = await page.content()
logger.error(f"Search state not resolved. Page URL: {page.url}")
logger.error(f"Page content snippet: {page_content[:2000]}")
raise AssertionError(f"Search did not complete: {e}")
logger.info("Results loaded")
# Phase 6: Verify visualization
# Check Plotly container is visible
viz_plot = page.locator("#viz-plot")
await viz_plot.wait_for(timeout=15000, state="visible")
logger.info("Plotly container is visible")
# Verify Plotly has rendered content (SVG/canvas elements inside)
has_viz_content = await page.evaluate(
"""
() => {
const plot = document.getElementById('viz-plot');
if (!plot) return false;
// Plotly creates .plotly class, canvas, or svg elements
return plot.children.length > 0 ||
plot.querySelector('.plotly, canvas, svg, .main-svg') !== null;
}
"""
)
assert has_viz_content, "Plotly visualization did not render any content"
logger.info("✓ Plotly visualization rendered content")
# Verify results are displayed
result_items = page.locator(".mcp-result-item")
result_count = await result_items.count()
assert result_count > 0, "No search results displayed"
logger.info(f"✓ Found {result_count} search result(s)")
# Verify our note appears in results
found_note = False
for i in range(result_count):
item = result_items.nth(i)
title_elem = item.locator(".mcp-result-title")
title_text = await title_elem.text_content()
if title_text and unique_term in title_text:
found_note = True
logger.info(f"✓ Found test note in results: {title_text}")
break
assert found_note, f"Created note with '{unique_term}' not found in results"
# Optional: Take screenshot for verification
await page.screenshot(path="/tmp/astrolabe_plotly_test_success.png")
logger.info("✓ All Plotly visualization assertions passed")
# Cleanup: delete the created note (inside the MCP client context)
if note_id:
try:
delete_response = await alice_mcp_client.call_tool(
"nc_notes_delete_note", {"note_id": note_id}
)
if not delete_response.isError:
logger.info(f"✓ Cleaned up test note {note_id}")
note_id = None # Mark as cleaned
else:
logger.warning(
f"Failed to delete note {note_id}: {delete_response}"
)
except Exception as e:
logger.warning(f"Cleanup failed for note {note_id}: {e}")
finally:
# Cleanup note if not already cleaned (create new client for cleanup)
if note_id:
try:
async for cleanup_client in create_mcp_client_session(
url="http://localhost:8003/mcp",
headers={"Authorization": auth_header},
client_name="Cleanup MCP",
):
delete_response = await cleanup_client.call_tool(
"nc_notes_delete_note", {"note_id": note_id}
)
if not delete_response.isError:
logger.info(f"✓ Cleaned up test note {note_id} (finally)")
else:
logger.warning(
f"Failed to delete note {note_id}: {delete_response}"
)
except Exception as e:
logger.warning(f"Cleanup failed for note {note_id}: {e}")
# Close browser context
await context.close()
@@ -0,0 +1,695 @@
"""Integration tests for Astrolabe token refresh flow.
Tests the token refresh mechanism between Astrolabe (Nextcloud app)
and the MCP server backend in a multi-user basic auth deployment.
This test verifies:
1. User provisions access via Astrolabe personal settings
2. Token is stored encrypted in Nextcloud database
3. Token expires (simulated via database manipulation)
4. MCP server requests new token via refresh
5. Astrolabe refreshes token with IdP
6. New token is stored and used successfully
Note: The mcp-multi-user-basic deployment uses "hybrid mode" which requires
BOTH OAuth authorization AND app password for full configuration. These tests
focus on the app password/credential storage aspects and verify database state
directly rather than relying on UI elements that require both steps.
"""
import logging
import re
import subprocess
import anyio
import pytest
from playwright.async_api import Page
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
logger = logging.getLogger(__name__)
async def login_to_nextcloud(page: Page, username: str, password: str):
"""Helper function to login to Nextcloud via Playwright.
Args:
page: Playwright page instance
username: Nextcloud username
password: Nextcloud password
"""
nextcloud_url = "http://localhost:8080"
logger.info(f"Logging in to Nextcloud as {username}...")
await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
# Fill in login form
await page.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
# Submit form
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=30000)
# Verify logged in (should redirect away from login page)
current_url = page.url
assert "/login" not in current_url, (
f"Login failed for {username}, still on login page"
)
logger.info(f"✓ Successfully logged in as {username}")
async def generate_app_password(
page: Page, username: str, app_name: str = "Astrolabe Test"
) -> str:
"""Generate an app password in Nextcloud Security settings.
Args:
page: Playwright page instance (must be authenticated)
username: Username (for logging)
app_name: Name for the app password
Returns:
The generated app password string
"""
logger.info(f"Generating app password for {username}...")
nextcloud_url = "http://localhost:8080"
# Navigate to Security settings
await page.goto(f"{nextcloud_url}/settings/user/security", wait_until="networkidle")
logger.info("Navigated to Security settings")
# Fill the app password input field
app_password_input = page.locator('input[placeholder="App name"]')
await app_password_input.fill(app_name)
logger.info(f"Entered app name: {app_name}")
# Wait for Vue.js to react and enable the button
await anyio.sleep(1.0)
# Click the create button
create_button = page.locator(
'button[type="submit"]:has-text("Create new app password")'
)
await create_button.click()
logger.info("Clicked create app password button")
# Wait for app password to be generated
await anyio.sleep(3)
# Find the generated app password
app_password = None
try:
await page.wait_for_selector('text="New app password"', timeout=10000)
logger.info("App password dialog appeared")
all_inputs = await page.locator('input[type="text"]').all()
for idx, input_elem in enumerate(all_inputs):
try:
value = await input_elem.input_value()
if value and "-" in value and len(value) > 20:
app_password = value.strip()
logger.info(f"Found app password in input {idx}")
break
except Exception:
continue
except Exception as e:
logger.error(f"Failed to find app password dialog: {e}")
if not app_password:
screenshot_path = f"/tmp/app_password_generation_{username}.png"
await page.screenshot(path=screenshot_path)
raise ValueError(
f"Could not find generated app password. Screenshot: {screenshot_path}"
)
# Validate password format
if not re.match(
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$",
app_password,
):
raise ValueError(f"App password format validation failed: {app_password}")
logger.info(f"✓ Generated app password for {username}")
# Close the dialog
close_button = page.get_by_role("button", name="Close")
await close_button.click()
await anyio.sleep(0.5)
return app_password
async def save_app_password_in_astrolabe(
page: Page, username: str, app_password: str
) -> bool:
"""Save app password in Astrolabe settings (Step 2 of hybrid mode).
This function only saves the app password - it does NOT verify the "Active"
badge since that requires both OAuth and app password in hybrid mode.
Args:
page: Playwright page instance
username: Username (for logging)
app_password: App password to enter
Returns:
True if the password was saved successfully (based on network response)
"""
logger.info(f"Saving app password in Astrolabe for {username}...")
nextcloud_url = "http://localhost:8080"
# Track network responses
credentials_response_status = None
def capture_response(resp):
nonlocal credentials_response_status
if "background-sync/credentials" in resp.url or "storeAppPassword" in resp.url:
credentials_response_status = resp.status
logger.info(f"Credentials endpoint response: {resp.status} {resp.url}")
page.on("response", capture_response)
# Navigate to Astrolabe settings
await page.goto(
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
)
await anyio.sleep(1)
# Check if Step 2 already shows "Complete"
try:
complete_badge = page.locator('text="Complete"').first
if await complete_badge.is_visible(timeout=2000):
logger.info(f"✓ App password already configured for {username}")
return True
except Exception:
pass
# Find the app password input field
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
try:
await app_password_input.wait_for(timeout=5000, state="visible")
logger.info("Found app password input field")
except Exception:
screenshot_path = f"/tmp/astrolabe_no_password_field_{username}.png"
await page.screenshot(path=screenshot_path)
raise ValueError(
f"Could not find app password input field. Screenshot: {screenshot_path}"
)
# Enter the app password
await app_password_input.fill(app_password)
logger.info(f"Entered app password for {username}")
await anyio.sleep(0.5)
# Click Save button
save_button = page.get_by_role("button", name="Save")
await save_button.click()
logger.info("Clicked Save button")
# Wait for the request to complete and page to reload
await page.wait_for_load_state("networkidle", timeout=15000)
await anyio.sleep(2)
# Verify the save was successful by checking network response
if credentials_response_status == 200:
logger.info(f"✓ App password saved successfully for {username}")
return True
else:
logger.error(
f"App password save failed for {username}, status: {credentials_response_status}"
)
screenshot_path = f"/tmp/astrolabe_save_failed_{username}.png"
await page.screenshot(path=screenshot_path)
return False
def get_background_sync_credentials(username: str) -> dict | None:
"""Get background sync credentials for a user from the database.
Args:
username: Nextcloud username
Returns:
Dict with credential details, or None if not found
"""
query = f"""
SELECT 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
if "background_sync_type" in output:
return {
"has_password": "background_sync_password" in output,
"has_type": "background_sync_type" in output,
"has_timestamp": "background_sync_provisioned_at" in output,
"is_app_password": "app_password" in output,
}
return None
except Exception as e:
logger.error(f"Error getting credentials for {username}: {e}")
return None
def delete_user_credentials(username: str) -> bool:
"""Delete all stored credentials for a user (for cleanup).
Args:
username: Nextcloud username
Returns:
True if successful
"""
query = f"""
DELETE FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey IN ('oauth_tokens', 'background_sync_password', 'background_sync_type', 'background_sync_provisioned_at');
"""
try:
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
logger.info(f"Deleted credentials for {username}")
return result.returncode == 0
except Exception as e:
logger.error(f"Error deleting credentials for {username}: {e}")
return False
@pytest.mark.integration
@pytest.mark.oauth
async def test_app_password_storage_and_cleanup(
browser,
nc_client,
test_users_setup,
configure_astrolabe_for_mcp_server,
):
"""Test that app passwords are stored and cleaned up correctly.
This test verifies:
1. User can save app password in Astrolabe settings
2. Password is stored encrypted in the database
3. Credentials can be revoked and are deleted from database
Note: In hybrid mode (mcp-multi-user-basic), this only tests Step 2
(app password storage). The "Active" badge requires both OAuth and
app password, which is tested separately.
"""
# Configure Astrolabe for mcp-multi-user-basic
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",
)
username = "alice"
user_config = test_users_setup[username]
password = user_config["password"]
# Cleanup any existing credentials
delete_user_credentials(username)
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
# Step 1: Login
await login_to_nextcloud(page, username, password)
# Step 2: Verify no credentials exist initially
initial_creds = get_background_sync_credentials(username)
assert initial_creds is None, f"Expected no credentials, found: {initial_creds}"
logger.info("✓ Verified no initial credentials")
# Step 3: Generate app password
app_password = await generate_app_password(page, username)
assert app_password, "Failed to generate app password"
# Step 4: Save app password in Astrolabe
save_success = await save_app_password_in_astrolabe(
page, username, app_password
)
assert save_success, "Failed to save app password"
# Step 5: Verify credentials are stored in database
stored_creds = get_background_sync_credentials(username)
assert stored_creds is not None, "Expected credentials to be stored"
assert stored_creds["has_password"], "Expected password to be stored"
assert stored_creds["has_type"], "Expected type to be stored"
assert stored_creds["is_app_password"], "Expected type to be 'app_password'"
logger.info("✓ Verified credentials stored in database")
# Step 6: Verify password is encrypted (not plaintext)
query = f"""
SELECT configvalue
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey = 'background_sync_password';
"""
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-N",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
encrypted_value = result.stdout.strip()
assert app_password not in encrypted_value, "Password appears in plaintext!"
assert len(encrypted_value) > len(app_password), (
"Encrypted value should be longer"
)
logger.info("✓ Verified password is encrypted")
finally:
await context.close()
# Cleanup
delete_user_credentials(username)
@pytest.mark.integration
@pytest.mark.oauth
async def test_credential_isolation_between_users(
browser,
nc_client,
test_users_setup,
configure_astrolabe_for_mcp_server,
):
"""Test that credentials are properly isolated between users.
This test verifies:
1. Multiple users can provision credentials independently
2. Each user's encrypted credentials are unique
3. Deleting one user's credentials doesn't affect others
"""
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
mcp_server_public_url="http://localhost:8003",
)
test_users = ["alice", "bob"]
user_passwords = {}
# Cleanup all users first
for username in test_users:
delete_user_credentials(username)
# Provision each user
for username in test_users:
user_config = test_users_setup[username]
password = user_config["password"]
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
await login_to_nextcloud(page, username, password)
app_password = await generate_app_password(
page, username, f"Test {username}"
)
save_success = await save_app_password_in_astrolabe(
page, username, app_password
)
assert save_success, f"Failed to save app password for {username}"
user_passwords[username] = app_password
# Verify stored
creds = get_background_sync_credentials(username)
assert creds is not None, f"Credentials not stored for {username}"
logger.info(f"✓ Credentials provisioned for {username}")
finally:
await context.close()
# Verify isolation - get encrypted values
encrypted_values = {}
for username in test_users:
query = f"""
SELECT configvalue
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey = 'background_sync_password';
"""
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-N",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
encrypted_values[username] = result.stdout.strip()
# Different users should have different encrypted values
assert encrypted_values["alice"] != encrypted_values["bob"], (
"Different users should have different encrypted values"
)
logger.info("✓ Verified credentials are unique per user")
# Delete alice's credentials and verify bob's are unaffected
delete_user_credentials("alice")
alice_creds = get_background_sync_credentials("alice")
bob_creds = get_background_sync_credentials("bob")
assert alice_creds is None, "Alice's credentials should be deleted"
assert bob_creds is not None, "Bob's credentials should still exist"
logger.info("✓ Verified credential deletion is isolated")
# Cleanup
for username in test_users:
delete_user_credentials(username)
@pytest.mark.integration
@pytest.mark.oauth
async def test_credential_revoke_and_reprovision(
browser,
nc_client,
test_users_setup,
configure_astrolabe_for_mcp_server,
):
"""Test that credentials can be revoked and reprovisioned.
This test verifies:
1. User provisions credentials
2. User revokes credentials (deletes from database)
3. User provisions again with new app password
4. New credentials are stored correctly
Note: The UI prevents overwriting credentials directly - users must
revoke first before provisioning new credentials.
"""
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
mcp_server_public_url="http://localhost:8003",
)
username = "alice"
user_config = test_users_setup[username]
password = user_config["password"]
delete_user_credentials(username)
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
await login_to_nextcloud(page, username, password)
# First provisioning
app_password_1 = await generate_app_password(page, username, "First Password")
await save_app_password_in_astrolabe(page, username, app_password_1)
# Get first encrypted value
query = f"""
SELECT configvalue
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey = 'background_sync_password';
"""
result1 = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-N",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
first_encrypted = result1.stdout.strip()
assert first_encrypted, "First credential should be stored"
logger.info("✓ First credential stored")
# Revoke credentials (simulating user clicking "Revoke Access")
delete_user_credentials(username)
logger.info("✓ Credentials revoked")
# Verify credentials are gone
creds_after_revoke = get_background_sync_credentials(username)
assert creds_after_revoke is None, "Credentials should be deleted after revoke"
# Second provisioning with different password
app_password_2 = await generate_app_password(page, username, "Second Password")
await save_app_password_in_astrolabe(page, username, app_password_2)
result2 = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-N",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
second_encrypted = result2.stdout.strip()
assert second_encrypted, "Second credential should be stored"
logger.info("✓ Second credential stored")
# Verify the encrypted values are different (different passwords)
assert first_encrypted != second_encrypted, (
"Different passwords should produce different encrypted values"
)
# Verify only one row exists
count_query = f"""
SELECT COUNT(*)
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey = 'background_sync_password';
"""
count_result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-N",
"-e",
count_query,
],
capture_output=True,
text=True,
timeout=10,
)
count = int(count_result.stdout.strip())
assert count == 1, f"Expected 1 credential row, found {count}"
logger.info("✓ Verified clean reprovision after revoke")
finally:
await context.close()
delete_user_credentials(username)
+177
View File
@@ -0,0 +1,177 @@
"""Integration tests for Deck card reorder functionality.
Tests issue #469: Moving Deck card from one column (stack) to another not working.
https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
"""
import logging
import uuid
import pytest
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
@pytest.fixture
async def board_with_two_stacks(nc_client: NextcloudClient):
"""Create a temporary board with two stacks for testing card movement.
Yields:
tuple: (board_data, source_stack_data, target_stack_data)
"""
unique_suffix = uuid.uuid4().hex[:8]
board_title = f"Reorder Test Board {unique_suffix}"
board = None
logger.info(f"Creating board with two stacks: {board_title}")
try:
board = await nc_client.deck.create_board(board_title, "0000FF")
board_id = board.id
# Create source stack (stack 1)
source_stack = await nc_client.deck.create_stack(
board_id, f"Source Stack {unique_suffix}", order=1
)
source_stack_data = {
"id": source_stack.id,
"title": source_stack.title,
"order": source_stack.order,
}
logger.info(f"Created source stack with ID: {source_stack.id}")
# Create target stack (stack 2)
target_stack = await nc_client.deck.create_stack(
board_id, f"Target Stack {unique_suffix}", order=2
)
target_stack_data = {
"id": target_stack.id,
"title": target_stack.title,
"order": target_stack.order,
}
logger.info(f"Created target stack with ID: {target_stack.id}")
board_data = {
"id": board_id,
"title": board.title,
"color": board.color,
}
yield (board_data, source_stack_data, target_stack_data)
finally:
if board:
logger.info(f"Cleaning up board ID: {board.id}")
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
logger.warning(f"Error cleaning up board: {e}")
async def test_reorder_card_move_to_different_stack(
nc_client: NextcloudClient, board_with_two_stacks: tuple
):
"""Test moving a card from one stack to another (issue #469).
This test reproduces the bug where the reorder_card API reports success
but the card doesn't actually move to the target stack.
"""
board_data, source_stack_data, target_stack_data = board_with_two_stacks
board_id = board_data["id"]
source_stack_id = source_stack_data["id"]
target_stack_id = target_stack_data["id"]
# Create a card in the source stack
unique_suffix = uuid.uuid4().hex[:8]
card_title = f"Test Card {unique_suffix}"
card = await nc_client.deck.create_card(
board_id, source_stack_id, card_title, description="Card to be moved"
)
card_id = card.id
logger.info(f"Created card ID: {card_id} in source stack ID: {source_stack_id}")
try:
# Verify card is in source stack
card_before = await nc_client.deck.get_card(board_id, source_stack_id, card_id)
assert card_before.stackId == source_stack_id, (
f"Card should start in source stack {source_stack_id}, "
f"but is in {card_before.stackId}"
)
logger.info(f"Verified card is in source stack: {source_stack_id}")
# Move card to target stack
logger.info(
f"Moving card {card_id} from stack {source_stack_id} "
f"to stack {target_stack_id}"
)
await nc_client.deck.reorder_card(
board_id=board_id,
stack_id=source_stack_id,
card_id=card_id,
order=0,
target_stack_id=target_stack_id,
)
logger.info("reorder_card API call completed")
# Verify card moved to target stack
# Note: After moving, the card should be accessible from the target stack
card_after = await nc_client.deck.get_card(board_id, target_stack_id, card_id)
assert card_after.stackId == target_stack_id, (
f"Card should have moved to target stack {target_stack_id}, "
f"but is in {card_after.stackId}"
)
logger.info(f"SUCCESS: Card moved to target stack {target_stack_id}")
finally:
# Clean up - try to delete from target stack first, then source
try:
await nc_client.deck.delete_card(board_id, target_stack_id, card_id)
except Exception:
try:
await nc_client.deck.delete_card(board_id, source_stack_id, card_id)
except Exception as e:
logger.warning(f"Error cleaning up card: {e}")
async def test_reorder_card_within_same_stack(
nc_client: NextcloudClient, board_with_two_stacks: tuple
):
"""Test reordering a card within the same stack (should work)."""
board_data, source_stack_data, _ = board_with_two_stacks
board_id = board_data["id"]
source_stack_id = source_stack_data["id"]
# Create two cards in the source stack
unique_suffix = uuid.uuid4().hex[:8]
card1 = await nc_client.deck.create_card(
board_id, source_stack_id, f"Card 1 {unique_suffix}", order=0
)
card2 = await nc_client.deck.create_card(
board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1
)
logger.info(f"Created cards {card1.id} (order 0) and {card2.id} (order 1)")
try:
# Reorder card1 to position after card2
await nc_client.deck.reorder_card(
board_id=board_id,
stack_id=source_stack_id,
card_id=card1.id,
order=2, # Move to position 2
target_stack_id=source_stack_id, # Same stack
)
logger.info(f"Reordered card {card1.id} to order 2")
# Verify card is still in the same stack
card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id)
assert card_after.stackId == source_stack_id
logger.info("Card reorder within same stack succeeded")
finally:
try:
await nc_client.deck.delete_card(board_id, source_stack_id, card1.id)
await nc_client.deck.delete_card(board_id, source_stack_id, card2.id)
except Exception as e:
logger.warning(f"Error cleaning up cards: {e}")
+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()
+6 -1
View File
@@ -89,8 +89,13 @@ async def test_create_operations_not_idempotent(nc_mcp_client: ClientSession):
"""Verify create operations are marked as non-idempotent."""
tools = await nc_mcp_client.list_tools()
# Exceptions: operations that are actually idempotent
# - calendar_create_meeting: creates or returns existing meeting
# - nc_webdav_create_directory: MKCOL returns 405 if exists (same end state)
idempotent_exceptions = {"calendar_create_meeting", "nc_webdav_create_directory"}
for tool in tools.tools:
if "create" in tool.name.lower() and "calendar_create_meeting" not in tool.name:
if "create" in tool.name.lower() and tool.name not in idempotent_exceptions:
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.idempotentHint is not True, (
f"Create tool {tool.name} should not be idempotent (creates new resources)"
+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"]
+227
View File
@@ -0,0 +1,227 @@
"""
Unit tests for App Password Storage functionality.
Tests the app password methods in RefreshTokenStorage for multi-user
BasicAuth mode background sync.
"""
import tempfile
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = pytest.mark.unit
@pytest.fixture
def encryption_key():
"""Generate a test encryption key."""
return Fernet.generate_key().decode()
@pytest.fixture
async def temp_storage(encryption_key):
"""Create temporary storage instance with encryption for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_app_passwords.db"
storage = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage.initialize()
yield storage
async def test_store_app_password(temp_storage):
"""Test storing an app password."""
await temp_storage.store_app_password(
user_id="testuser",
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
)
# Verify it can be retrieved
retrieved = await temp_storage.get_app_password("testuser")
assert retrieved == "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
async def test_store_app_password_replaces_existing(temp_storage):
"""Test that storing a new app password replaces the existing one."""
await temp_storage.store_app_password(
user_id="testuser",
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
)
await temp_storage.store_app_password(
user_id="testuser",
app_password="fffff-ggggg-hhhhh-iiiii-jjjjj",
)
retrieved = await temp_storage.get_app_password("testuser")
assert retrieved == "fffff-ggggg-hhhhh-iiiii-jjjjj"
async def test_get_app_password_nonexistent(temp_storage):
"""Test retrieving app password for non-existent user."""
retrieved = await temp_storage.get_app_password("nonexistent")
assert retrieved is None
async def test_delete_app_password(temp_storage):
"""Test deleting an app password."""
await temp_storage.store_app_password(
user_id="testuser",
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
)
deleted = await temp_storage.delete_app_password("testuser")
assert deleted is True
# Verify it's gone
retrieved = await temp_storage.get_app_password("testuser")
assert retrieved is None
async def test_delete_app_password_nonexistent(temp_storage):
"""Test deleting non-existent app password."""
deleted = await temp_storage.delete_app_password("nonexistent")
assert deleted is False
async def test_get_all_app_password_user_ids(temp_storage):
"""Test listing all users with app passwords."""
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
await temp_storage.store_app_password("charlie", "ccccc-ccccc-ccccc-ccccc-ccccc")
user_ids = await temp_storage.get_all_app_password_user_ids()
assert len(user_ids) == 3
assert "alice" in user_ids
assert "bob" in user_ids
assert "charlie" in user_ids
async def test_get_all_app_password_user_ids_empty(temp_storage):
"""Test listing users when none have app passwords."""
user_ids = await temp_storage.get_all_app_password_user_ids()
assert len(user_ids) == 0
async def test_app_password_encryption(encryption_key):
"""Test that app passwords are encrypted at rest."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_encryption.db"
storage = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage.initialize()
# Store a password
test_password = "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
await storage.store_app_password("testuser", test_password)
# Read directly from database to verify encryption
import aiosqlite
async with aiosqlite.connect(str(db_path)) as db:
async with db.execute(
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
("testuser",),
) as cursor:
row = await cursor.fetchone()
# The stored value should be encrypted (not plain text)
encrypted_bytes = row[0]
assert encrypted_bytes != test_password.encode()
# Encrypted data should be longer due to Fernet overhead
assert len(encrypted_bytes) > len(test_password)
async def test_app_password_requires_encryption_key():
"""Test that app password operations require encryption key."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_no_key.db"
storage = RefreshTokenStorage(db_path=str(db_path), encryption_key=None)
await storage.initialize()
# Storing should fail without encryption key
with pytest.raises(RuntimeError, match="Encryption key not configured"):
await storage.store_app_password(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
# Getting should also fail without encryption key
with pytest.raises(RuntimeError, match="Encryption key not configured"):
await storage.get_app_password("testuser")
async def test_multiple_users_independence(temp_storage):
"""Test that different users maintain independent app passwords."""
users = ["alice", "bob", "charlie", "diana"]
# Store unique passwords for each user
for i, user in enumerate(users):
password = (
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
)
await temp_storage.store_app_password(user, password)
# Verify each user has their correct password
for user in users:
expected = (
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
)
retrieved = await temp_storage.get_app_password(user)
assert retrieved == expected
# Delete one user's password
await temp_storage.delete_app_password("bob")
# Verify other users unchanged
for user in ["alice", "charlie", "diana"]:
retrieved = await temp_storage.get_app_password(user)
assert retrieved is not None
# Verify bob's password is gone
assert await temp_storage.get_app_password("bob") is None
async def test_app_password_with_special_characters(temp_storage):
"""Test storing passwords with various alphanumeric patterns."""
# Nextcloud app passwords use alphanumeric characters
passwords = [
"AAAAA-BBBBB-CCCCC-DDDDD-EEEEE", # uppercase
"aaaaa-bbbbb-ccccc-ddddd-eeeee", # lowercase
"12345-67890-12345-67890-12345", # numbers
"aB1cD-eF2gH-iJ3kL-mN4oP-qR5sT", # mixed
]
for i, password in enumerate(passwords):
user = f"user{i}"
await temp_storage.store_app_password(user, password)
retrieved = await temp_storage.get_app_password(user)
assert retrieved == password
async def test_decryption_with_wrong_key(encryption_key):
"""Test that decryption fails with wrong key."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_wrong_key.db"
# Store with original key
storage1 = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage1.initialize()
await storage1.store_app_password("testuser", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
# Try to read with different key
wrong_key = Fernet.generate_key().decode()
storage2 = RefreshTokenStorage(db_path=str(db_path), encryption_key=wrong_key)
await storage2.initialize()
# Decryption should fail and return None (graceful handling)
retrieved = await storage2.get_app_password("testuser")
assert retrieved is None
+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)
@@ -0,0 +1,624 @@
"""
Unit tests for Management API app password endpoints.
Tests the REST API endpoints for multi-user BasicAuth mode app password management:
- POST /api/v1/users/{user_id}/app-password - Provision app password
- GET /api/v1/users/{user_id}/app-password - Check status
- DELETE /api/v1/users/{user_id}/app-password - Delete app password
"""
import base64
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from cryptography.fernet import Fernet
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from nextcloud_mcp_server.api import passwords
from nextcloud_mcp_server.api.passwords import (
delete_app_password,
get_app_password_status,
provision_app_password,
)
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = pytest.mark.unit
@pytest.fixture(autouse=True)
def clear_rate_limit():
"""Clear rate limit state before each test."""
passwords._rate_limit_attempts.clear()
yield
passwords._rate_limit_attempts.clear()
@pytest.fixture
def encryption_key():
"""Generate a test encryption key."""
return Fernet.generate_key().decode()
@pytest.fixture
async def temp_storage(encryption_key):
"""Create temporary storage instance with encryption for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_management.db"
storage = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage.initialize()
yield storage
def create_basic_auth_header(username: str, password: str) -> str:
"""Create BasicAuth header value."""
credentials = f"{username}:{password}"
encoded = base64.b64encode(credentials.encode()).decode()
return f"Basic {encoded}"
def create_test_app(storage):
"""Create a test Starlette app with the management endpoints."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
Route(
"/api/v1/users/{user_id}/app-password",
get_app_password_status,
methods=["GET"],
),
Route(
"/api/v1/users/{user_id}/app-password",
delete_app_password,
methods=["DELETE"],
),
]
)
app.state.storage = storage
return app
async def test_provision_app_password_missing_auth():
"""Test that missing auth returns 401."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
response = client.post("/api/v1/users/testuser/app-password")
assert response.status_code == 401
assert "Missing BasicAuth" in response.json()["error"]
async def test_provision_app_password_invalid_auth_format():
"""Test that invalid auth format returns 401."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
response = client.post(
"/api/v1/users/testuser/app-password",
headers={"Authorization": "Basic invalid-not-base64!!!"},
)
assert response.status_code == 401
assert "Invalid BasicAuth" in response.json()["error"]
async def test_provision_app_password_username_mismatch():
"""Test that username mismatch returns 403."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
# Try to provision for "testuser" but auth as "otheruser"
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 403
assert "does not match" in response.json()["error"]
async def test_provision_app_password_invalid_format():
"""Test that invalid app password format returns 400."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
# Use invalid password format (not xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header("testuser", "invalid-password")
},
)
assert response.status_code == 400
assert "Invalid app password format" in response.json()["error"]
async def test_provision_app_password_success(temp_storage, mocker):
"""Test successful app password provisioning."""
# Mock settings (imported locally in the function)
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client for Nextcloud validation
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"ocs": {"data": {"id": "testuser"}}}
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
return_value=mock_client,
)
# Create app with storage
app = create_test_app(temp_storage)
client = TestClient(app)
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "stored" in data["message"].lower()
# Verify password was stored
stored_password = await temp_storage.get_app_password("testuser")
assert stored_password == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
async def test_provision_app_password_nextcloud_validation_fails(mocker):
"""Test that failed Nextcloud validation returns 401."""
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client to return 401
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
return_value=mock_client,
)
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 401
assert "Invalid app password" in response.json()["error"]
async def test_get_app_password_status_provisioned(temp_storage, mocker):
"""Test checking status when app password is provisioned."""
# Store an app password
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
app = create_test_app(temp_storage)
client = TestClient(app)
response = client.get(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["user_id"] == "testuser"
assert data["has_app_password"] is True
async def test_get_app_password_status_not_provisioned(temp_storage, mocker):
"""Test checking status when app password is not provisioned."""
app = create_test_app(temp_storage)
client = TestClient(app)
response = client.get(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["user_id"] == "testuser"
assert data["has_app_password"] is False
async def test_get_app_password_status_username_mismatch():
"""Test that username mismatch returns 403 for status check."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
get_app_password_status,
methods=["GET"],
),
]
)
client = TestClient(app)
response = client.get(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 403
async def test_delete_app_password_success(temp_storage, mocker):
"""Test successful app password deletion."""
# Store an app password
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
# Mock settings (imported locally in the function)
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client for Nextcloud validation
mock_response = MagicMock()
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
return_value=mock_client,
)
app = create_test_app(temp_storage)
client = TestClient(app)
response = client.delete(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "deleted" in data["message"].lower()
# Verify password was removed
stored_password = await temp_storage.get_app_password("testuser")
assert stored_password is None
async def test_delete_app_password_not_found(temp_storage, mocker):
"""Test deleting non-existent app password."""
# Mock settings (imported locally in the function)
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client for Nextcloud validation
mock_response = MagicMock()
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
return_value=mock_client,
)
app = create_test_app(temp_storage)
client = TestClient(app)
response = client.delete(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "no app password found" in data["message"].lower()
async def test_delete_app_password_invalid_credentials(mocker):
"""Test that invalid credentials returns 401 for deletion."""
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client to return 401
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
return_value=mock_client,
)
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
delete_app_password,
methods=["DELETE"],
),
]
)
client = TestClient(app)
response = client.delete(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "wrong-password-xxxxx"
)
},
)
assert response.status_code == 401
assert "Invalid credentials" in response.json()["error"]
async def test_delete_app_password_username_mismatch():
"""Test that username mismatch returns 403 for deletion."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
delete_app_password,
methods=["DELETE"],
),
]
)
client = TestClient(app)
response = client.delete(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 403
async def test_provision_app_password_rate_limiting(mocker):
"""Test that rate limiting blocks excessive provisioning attempts."""
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client to return 401 (failed validation)
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
return_value=mock_client,
)
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
# Make 5 failed attempts (should all return 401)
for i in range(5):
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 401, f"Attempt {i + 1} should return 401"
# 6th attempt should be rate limited (429)
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 429
assert "Rate limit exceeded" in response.json()["error"]
assert "Retry-After" in response.headers
async def test_rate_limiting_is_per_user(mocker):
"""Test that rate limiting is applied per user, not globally."""
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client to return 401
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
return_value=mock_client,
)
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
# Make 5 failed attempts for user1 (hits rate limit)
for _ in range(5):
client.post(
"/api/v1/users/user1/app-password",
headers={
"Authorization": create_basic_auth_header(
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
# user1 should be rate limited
response = client.post(
"/api/v1/users/user1/app-password",
headers={
"Authorization": create_basic_auth_header(
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 429
# user2 should NOT be rate limited (different user)
response = client.post(
"/api/v1/users/user2/app-password",
headers={
"Authorization": create_basic_auth_header(
"user2", "bbbbb-ccccc-ddddd-eeeee-fffff"
)
},
)
assert response.status_code == 401 # Fails validation, but not rate limited

Some files were not shown because too many files have changed in this diff Show More