Compare commits

...

431 Commits

Author SHA1 Message Date
github-actions[bot] 1176479ec1 bump: version 0.64.4 → 0.64.5 2026-03-03 08:33:47 +00:00
Chris Coutinho 0f8b1c6325 Merge pull request #602 from cbcoutinho/fix/contacts-vcard-dict-format-601
fix: handle pythonvCard4 dict-format fields and missing phones (#601)
2026-03-03 09:33:27 +01:00
Chris Coutinho fdb7b87baf fix: handle pythonvCard4 dict-format fields and missing phone numbers (#601)
Fix three related contacts bugs:
- Parse dict-format vCard fields ({value, type}) that pythonvCard4 returns,
  which previously crashed Pydantic validation expecting plain strings
- Include tel field in client output so phone numbers reach MCP tools
- Clarify addressbook parameter expects URI slug, not displayname

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:32:53 +01:00
github-actions[bot] 184415eca1 bump: version 0.57.92 → 0.57.93 2026-03-03 06:13:03 +00:00
Chris Coutinho 658fd7e138 Merge pull request #600 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.66
2026-03-03 07:12:48 +01:00
renovate-bot-cbcoutinho[bot] a5d2025797 chore(deps): update anthropics/claude-code-action action to v1.0.66 2026-03-02 17:17:24 +00:00
github-actions[bot] 08aaa85ab3 bump: version 0.57.91 → 0.57.92 2026-03-02 11:35:44 +00:00
Chris Coutinho ecab777efa Merge pull request #598 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.65
2026-03-02 12:35:28 +01:00
github-actions[bot] c960560716 bump: version 0.57.90 → 0.57.91 2026-03-02 11:33:46 +00:00
Chris Coutinho 023927afff Merge pull request #599 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.47.0
2026-03-02 12:33:28 +01:00
renovate-bot-cbcoutinho[bot] 3a87b33288 chore(deps): update helm release ollama to v1.47.0 2026-03-02 11:15:34 +00:00
renovate-bot-cbcoutinho[bot] c8ebd9c089 chore(deps): update anthropics/claude-code-action action to v1.0.65 2026-03-02 11:15:16 +00:00
github-actions[bot] 5d81d60262 bump: version 0.57.89 → 0.57.90 2026-03-01 16:26:00 +00:00
Chris Coutinho b86e798ba8 Merge pull request #592 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.64
2026-03-01 17:25:47 +01:00
Chris Coutinho a7d623733b Merge pull request #591 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 f3fa41d
2026-03-01 17:25:35 +01:00
github-actions[bot] 3311b20ef6 bump: version 0.57.88 → 0.57.89 2026-03-01 16:24:44 +00:00
Chris Coutinho 28c7f1cdbd Merge pull request #594 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.7
2026-03-01 17:24:30 +01:00
github-actions[bot] 2713f74be6 bump: version 0.57.87 → 0.57.88 2026-03-01 16:23:38 +00:00
Chris Coutinho e3c5a87b22 Merge pull request #590 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.6
chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to 5c4e09f
2026-03-01 17:23:22 +01:00
Chris Coutinho 53cf223a56 Merge pull request #595 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.46.0
2026-03-01 17:23:11 +01:00
Chris Coutinho 6bfde0de1f Merge pull request #596 from cbcoutinho/renovate/major-github-artifact-actions
chore(deps): update actions/upload-artifact action to v7
2026-03-01 17:22:58 +01:00
github-actions[bot] 8cf3264914 bump: version 0.57.86 → 0.57.87 2026-03-01 16:05:13 +00:00
Chris Coutinho ed2f400ed8 Merge pull request #593 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.3.1
2026-03-01 17:04:56 +01:00
renovate-bot-cbcoutinho[bot] 6ba598afd1 chore(deps): update actions/upload-artifact action to v7 2026-03-01 16:03:54 +00:00
renovate-bot-cbcoutinho[bot] d0bfecea97 chore(deps): update helm release ollama to v1.46.0 2026-03-01 16:03:49 +00:00
renovate-bot-cbcoutinho[bot] bf0a4ac5d3 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.7 2026-03-01 16:03:28 +00:00
renovate-bot-cbcoutinho[bot] 3da6feba41 chore(deps): update astral-sh/setup-uv action to v7.3.1 2026-03-01 16:03:20 +00:00
renovate-bot-cbcoutinho[bot] 1224090469 chore(deps): update anthropics/claude-code-action action to v1.0.64 2026-03-01 16:03:13 +00:00
renovate-bot-cbcoutinho[bot] aa624401c3 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to f3fa41d 2026-03-01 16:03:08 +00:00
renovate-bot-cbcoutinho[bot] 61e867397c chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to 5c4e09f 2026-03-01 16:03:02 +00:00
github-actions[bot] 5796e2ba54 bump: version 0.57.85 → 0.57.86 2026-02-26 15:42:35 +00:00
github-actions[bot] 37141ea79f bump: version 0.64.3 → 0.64.4 2026-02-26 15:42:35 +00:00
Chris Coutinho 68126f6fe3 Merge pull request #555 from cbcoutinho/renovate/icalendar-7.x
fix(deps): update dependency icalendar to v7
2026-02-26 16:42:12 +01:00
github-actions[bot] 78b934ffa6 bump: version 0.57.84 → 0.57.85 2026-02-25 12:43:46 +00:00
Chris Coutinho 01a9ad5278 Merge pull request #588 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.60
2026-02-25 13:43:30 +01:00
github-actions[bot] b67a566902 bump: version 0.57.83 → 0.57.84 2026-02-25 11:35:38 +00:00
Chris Coutinho c9e8a56355 Merge pull request #584 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.6
chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to dcf9c60
2026-02-25 12:35:22 +01:00
renovate-bot-cbcoutinho[bot] 785ba5bf09 chore(deps): update anthropics/claude-code-action action to v1.0.60 2026-02-25 11:18:33 +00:00
renovate-bot-cbcoutinho[bot] 159ffb6110 chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to dcf9c60 2026-02-25 11:18:25 +00:00
github-actions[bot] 70139c4782 bump: version 0.57.82 → 0.57.83 2026-02-25 09:14:58 +00:00
Chris Coutinho a922187489 Merge pull request #586 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.59
2026-02-25 10:14:43 +01:00
github-actions[bot] 1ba6a142f5 bump: version 0.57.81 → 0.57.82 2026-02-25 08:42:21 +00:00
Chris Coutinho 79478f2483 Merge pull request #585 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 39e4e1c
2026-02-25 09:42:03 +01:00
github-actions[bot] 4721a5da52 bump: version 0.57.80 → 0.57.81 2026-02-25 07:26:59 +00:00
Chris Coutinho be2b683604 Merge pull request #587 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.6
2026-02-25 08:26:45 +01:00
renovate-bot-cbcoutinho[bot] 9fd3d92a0f chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.6 2026-02-25 05:15:47 +00:00
renovate-bot-cbcoutinho[bot] ceebda071f chore(deps): update anthropics/claude-code-action action to v1.0.59 2026-02-25 05:15:42 +00:00
renovate-bot-cbcoutinho[bot] 26fc48dc46 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 39e4e1c 2026-02-25 05:15:37 +00:00
renovate-bot-cbcoutinho[bot] 3edc226d17 fix(deps): update dependency icalendar to v7 2026-02-24 17:17:49 +00:00
github-actions[bot] 7384b47795 bump: version 0.57.79 → 0.57.80 2026-02-24 12:44:15 +00:00
Chris Coutinho b62d275dc9 Merge pull request #583 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.57
2026-02-24 13:43:57 +01:00
renovate-bot-cbcoutinho[bot] a0fa0230ab chore(deps): update anthropics/claude-code-action action to v1.0.57 2026-02-24 11:15:33 +00:00
github-actions[bot] 7314097483 bump: version 0.57.78 → 0.57.79 2026-02-24 09:34:47 +00:00
Chris Coutinho 3d070f74c5 Merge pull request #581 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.5
2026-02-24 10:34:30 +01:00
github-actions[bot] 80366a4e1e bump: version 0.57.77 → 0.57.78 2026-02-24 08:21:58 +00:00
Chris Coutinho 91941a9ece Merge pull request #582 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.56
2026-02-24 09:21:39 +01:00
github-actions[bot] 8fd6f4158f bump: version 0.57.76 → 0.57.77 2026-02-24 08:19:59 +00:00
Chris Coutinho b8e6539b6f Merge pull request #580 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 ba6cb07
2026-02-24 09:19:15 +01:00
github-actions[bot] fe53e93fe9 bump: version 0.57.75 → 0.57.76 2026-02-24 07:51:52 +00:00
Chris Coutinho 71d4c44b05 Merge pull request #579 from cbcoutinho/renovate/docker.io-library-redis-alpine
chore(deps): update docker.io/library/redis:alpine docker digest to 2afba59
2026-02-24 08:51:37 +01:00
renovate-bot-cbcoutinho[bot] 8261048741 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.5 2026-02-24 05:14:52 +00:00
renovate-bot-cbcoutinho[bot] 6443aca743 chore(deps): update anthropics/claude-code-action action to v1.0.56 2026-02-24 05:14:46 +00:00
renovate-bot-cbcoutinho[bot] a1b5e676e9 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to ba6cb07 2026-02-23 23:14:33 +00:00
renovate-bot-cbcoutinho[bot] 1d9168f614 chore(deps): update docker.io/library/redis:alpine docker digest to 2afba59 2026-02-23 23:14:28 +00:00
github-actions[bot] 9229440a58 bump: version 0.57.74 → 0.57.75 2026-02-23 06:08:54 +00:00
Chris Coutinho e507f29e83 Merge pull request #578 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.45.0
2026-02-23 07:08:40 +01:00
renovate-bot-cbcoutinho[bot] 5ac6d8d396 chore(deps): update helm release ollama to v1.45.0 2026-02-23 05:14:16 +00:00
github-actions[bot] ab71003c5d bump: version 0.57.73 → 0.57.74 2026-02-21 08:53:06 +00:00
Chris Coutinho 726b71eea1 Merge pull request #575 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.4
2026-02-21 09:52:51 +01:00
github-actions[bot] 3e50924169 bump: version 0.57.72 → 0.57.73 2026-02-21 08:38:08 +00:00
github-actions[bot] b2773317ef bump: version 0.64.2 → 0.64.3 2026-02-21 08:38:07 +00:00
Chris Coutinho dce3ca9a70 Merge pull request #574 from cbcoutinho/fix/wrap-list-returns-in-response-models
fix: wrap raw list returns in response models (#568)
2026-02-21 09:37:48 +01:00
Chris Coutinho 18e5baf2a5 fix: address PR #574 fourth review round
- Use lowercase generics (list[...]) in new deck response models
- Add clarifying comment on AddressBook.uri slug semantics
- Fall back calendar_display_name to calendar_name when absent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 09:36:14 +01:00
github-actions[bot] 24bc29ea64 bump: version 0.57.71 → 0.57.72 2026-02-20 18:23:30 +00:00
Chris Coutinho 44e7e2e09b Merge pull request #577 from cbcoutinho/renovate/qdrant-1.x
chore(deps): update helm release qdrant to v1.17.0
2026-02-20 19:23:12 +01:00
renovate-bot-cbcoutinho[bot] bcc0bfee8d chore(deps): update helm release qdrant to v1.17.0 2026-02-20 17:15:57 +00:00
github-actions[bot] 0f31d16158 bump: version 0.57.70 → 0.57.71 2026-02-20 13:33:00 +00:00
Chris Coutinho 7c0b84d398 Merge pull request #576 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.44.0
2026-02-20 14:32:44 +01:00
Chris Coutinho f51b27ba19 fix: address PR #574 third review round
- Guard board.labels against None in deck_get_labels and resource
- Add TODO comments for calendar_display_name in single-calendar paths
- Document _raw_contact_to_model scope limitation (maps only what the
  client returns; expanding requires changes to vCard parsing)
- Log debug warning when event has no start_datetime
- Verified Table model is safe with extra fields (Pydantic v2 ignores)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:49:55 +01:00
renovate-bot-cbcoutinho[bot] 010eb40d5c chore(deps): update helm release ollama to v1.44.0 2026-02-20 11:15:26 +00:00
renovate-bot-cbcoutinho[bot] 960d060d27 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.4 2026-02-20 11:15:05 +00:00
Chris Coutinho 76e6c12b56 fix: address PR #574 second review round
- Enrich single-calendar event dicts with calendar_name before mapping
  to CalendarEventSummary (list_events and upcoming_events paths)
- Extract _raw_contact_to_model() from inline mapping in contacts.py,
  fix custom_fields type annotation to dict[str, Any]
- Add unit tests for _event_dict_to_summary covering categories parsing,
  falsy coercion, and calendar name passthrough
- Replace duplicated test helper with import of production function

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:57:09 +01:00
Chris Coutinho 76e305006c fix: address PR #574 review comments
Restore contact email/birthday/nickname data and per-event calendar
source that were silently dropped during response model wrapping.
Remove dead elif branches in OAuth deck tests, add regression tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 09:39:46 +01:00
Chris Coutinho 8887aa241a fix: wrap raw list returns in response models to produce single TextContent block
MCP tools returning raw lists caused FastMCP's _convert_to_content() to create
one TextContent block per element. Most MCP clients only read content[0], so
they saw a single result instead of the full list.

Wrapped 9 tool functions in proper response objects:
- deck: deck_get_boards, deck_get_stacks, deck_get_cards, deck_get_labels
- calendar: nc_calendar_list_events, nc_calendar_get_upcoming_events
- contacts: nc_contacts_list_addressbooks, nc_contacts_list_contacts
- tables: nc_tables_list_tables

Closes #568

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 09:22:16 +01:00
github-actions[bot] 10d44edf4c bump: version 0.57.69 → 0.57.70 2026-02-20 07:15:37 +00:00
github-actions[bot] f5b4658d5a bump: version 0.64.1 → 0.64.2 2026-02-20 07:15:37 +00:00
Chris Coutinho 39d160ce48 Merge pull request #571 from cbcoutinho/fix/astrolabe-revoke-test-stale-credentials
fix: resolve stale credentials causing astrolabe test failures
2026-02-20 08:15:17 +01:00
Chris Coutinho a11ae9c027 refactor: enforce PLC0415 (import-outside-top-level) for source code
Enable ruff PLC0415 rule for all source files (tests excluded via
per-file-ignores). Move 136 inline imports to top-level across 33 files.
8 imports suppressed with noqa for legitimate reasons: circular
dependencies (client/__init__.py, context.py), optional dependency
guards (app.py document processors, auth/userinfo_routes.py), and
post-env-setup imports (smithery_main.py).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:04:50 +01:00
Chris Coutinho 81efa6e263 fix: address PR #571 review comments
- Move httpx import to top-level and use anyio task group for concurrent
  validation in cleanup_invalid_app_passwords (storage.py)
- Respect Retry-After header for 429 responses, capped at 300s (oauth_sync.py)
- Soften pre-validation exceptions so transient failures don't crash the
  background sync task (oauth_sync.py)
- Replace f-string SQL with blanket DELETE and add returncode checks (conftest.py)
- Extract clear_stale_test_state() helper to deduplicate cleanup logic
  in astrolabe background sync tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:03:55 +01:00
github-actions[bot] aaddd0d5a9 bump: version 0.57.68 → 0.57.69 2026-02-20 06:11:59 +00:00
Chris Coutinho a5eb16c1ac Merge pull request #573 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.55
2026-02-20 07:11:44 +01:00
renovate-bot-cbcoutinho[bot] 6f7a06e558 chore(deps): update anthropics/claude-code-action action to v1.0.55 2026-02-20 05:14:18 +00:00
github-actions[bot] 0e4c8453bf bump: version 0.57.67 → 0.57.68 2026-02-19 21:02:59 +00:00
Chris Coutinho 2dba3179bd Merge pull request #572 from cbcoutinho/renovate/docker.io-qdrant-qdrant-1.x
chore(deps): update docker.io/qdrant/qdrant docker tag to v1.17.0
2026-02-19 22:02:44 +01:00
renovate-bot-cbcoutinho[bot] 5f0e208193 chore(deps): update docker.io/qdrant/qdrant docker tag to v1.17.0 2026-02-19 17:16:34 +00:00
Chris Coutinho 3779ec3e17 fix: resolve stale credentials causing astrolabe background sync test failures
The revoke test failed because it only completed Step 2 (app password) but
not Step 1 (OAuth authorization). In hybrid mode, Astrolabe requires both
steps for $isFullyConfigured=true, which gates the "Revoke Access" button.

Changes:
- Use complete_astrolabe_authorization() in revoke test for full two-step flow
- Add stale state cleanup (app passwords, bruteforce entries, Astrolabe prefs)
  to both enablement and revoke tests
- Add startup cleanup of invalid app passwords in BasicAuth mode
- Pre-validate credentials before entering scanner loop to fail fast
- Handle 401/403/429 in scanner with proper backoff and circuit breaking
- Clean up app passwords in test_users_setup fixture teardown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:55:58 +01:00
github-actions[bot] f2df19c39b bump: version 0.57.66 → 0.57.67 2026-02-19 06:28:37 +00:00
Chris Coutinho 5562c943c0 Merge pull request #569 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.54
2026-02-19 07:28:20 +01:00
renovate-bot-cbcoutinho[bot] 12c02ffe00 chore(deps): update anthropics/claude-code-action action to v1.0.54 2026-02-18 23:20:15 +00:00
github-actions[bot] d2e1391f37 bump: version 0.57.65 → 0.57.66 2026-02-18 15:13:58 +00:00
Chris Coutinho ac91aacaf5 Merge pull request #567 from cbcoutinho/renovate/docker-login-action-3.x
chore(deps): update docker/login-action action to v3.7.0
2026-02-18 16:13:41 +01:00
github-actions[bot] ad9fcddca1 bump: version 0.57.64 → 0.57.65 2026-02-18 14:50:14 +00:00
Chris Coutinho 0e57cf6389 Merge pull request #566 from cbcoutinho/renovate/docker-build-push-action-6.x
chore(deps): update docker/build-push-action action to v6.19.2
2026-02-18 15:49:58 +01:00
github-actions[bot] b9a185ba1c bump: version 0.57.63 → 0.57.64 2026-02-18 14:47:47 +00:00
Chris Coutinho 9aa6b44397 Merge pull request #392 from cbcoutinho/renovate/major-github-artifact-actions
chore(deps): update actions/upload-artifact action to v6
2026-02-18 15:47:27 +01:00
github-actions[bot] 1aa21663b7 bump: version 0.57.62 → 0.57.63 2026-02-18 14:31:35 +00:00
Chris Coutinho d145e4d5de Merge pull request #431 from cbcoutinho/renovate/actions-setup-python-6.x
chore(deps): update actions/setup-python action to v6
2026-02-18 15:31:19 +01:00
github-actions[bot] cf4ed4a641 bump: version 0.57.61 → 0.57.62 2026-02-18 11:38:32 +00:00
github-actions[bot] 8d84d95ada bump: version 0.64.0 → 0.64.1 2026-02-18 11:38:32 +00:00
Chris Coutinho 992d380585 Merge pull request #390 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.26,<1.27
2026-02-18 12:38:11 +01:00
renovate-bot-cbcoutinho[bot] e51fc48206 chore(deps): update actions/upload-artifact action to v6 2026-02-18 11:15:53 +00:00
renovate-bot-cbcoutinho[bot] 2657071404 chore(deps): update actions/setup-python action to v6 2026-02-18 11:15:45 +00:00
renovate-bot-cbcoutinho[bot] 75325f16fc fix(deps): update dependency mcp to >=1.26,<1.27 2026-02-18 11:15:38 +00:00
renovate-bot-cbcoutinho[bot] 1d4ff3fbe0 chore(deps): update docker/login-action action to v3.7.0 2026-02-18 11:14:51 +00:00
renovate-bot-cbcoutinho[bot] 778b08cc84 chore(deps): update docker/build-push-action action to v6.19.2 2026-02-18 11:14:45 +00:00
github-actions[bot] 8cab588f21 bump: version 0.57.60 → 0.57.61 2026-02-18 10:27:23 +00:00
Chris Coutinho 8233cc9dcf Merge pull request #523 from cbcoutinho/feature/adr-022-deployment-mode-consolidation
docs: ADR-022 Deployment Mode Consolidation via Login Flow v2
2026-02-18 11:27:05 +01:00
Chris Coutinho 0d259d2dfd docs(ADR-022): concrete Smithery rationale + app password lifecycle
Address reviewer feedback on two fronts:

- Replace vague privacy-only Smithery deprecation rationale with concrete
  justification: free tier sunsetting March 2026 (primary), privacy as
  secondary. Updated in context, migration table, and Alternative 5.

- Add App Password Lifecycle Management section covering stale/revoked
  password detection (401 handling), login flow session cleanup (background
  task), and optional password rotation (APP_PASSWORD_MAX_AGE_DAYS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:19:13 +01:00
github-actions[bot] dfc676a847 bump: version 0.57.59 → 0.57.60 2026-02-18 09:10:51 +00:00
Chris Coutinho cf627a9c48 Merge pull request #565 from cbcoutinho/ci/pin-gha-semver-comments
ci: pin GitHub Actions version comments to full semver
2026-02-18 10:10:33 +01:00
Chris Coutinho 037e88e416 ci: pin GitHub Actions version comments to full semver
Renovate's helpers:pinGitHubActionDigestsToSemver preset reads version
comments to track updates. Major-only comments (e.g. # v6) produce
unhelpful changelog diffs like "v6 → v6". Full semver comments
(e.g. # v6.0.2) let Renovate show meaningful version changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:50:12 +01:00
Chris Coutinho dae2f276ae docs(ADR-022): address reviewer feedback
Changes based on review:

1. Add Nextcloud platform limitation section documenting OAuth/scope
   support by endpoint type (WebDAV supports OAuth, others don't)

2. Update MCP elicitation to show capability negotiation and graceful
   fallback - URL in error message when elicitation not supported

3. Simplify Smithery section - recommend self-hosted for privacy,
   don't detail platform changes

4. Expand re-auth section with scope merging behavior, scenarios table,
   and explicit design choice for tool-based re-auth over auto-elicitation

5. Make rate limiting configurable with environment variables and
   admin guidance by deployment size

6. Clarify OAuth alternative - keep simple now, revisit if Nextcloud
   adds scoped OAuth support

7. Expand verification steps with required tests, add recommended
   Nextcloud configuration, add required README security notice

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 09:23:32 +01:00
Chris Coutinho d94610d0ec docs: add ADR-022 for deployment mode consolidation via Login Flow v2
Proposes consolidating five deployment modes into two:
- Single-User: App password in env vars (trusted environment)
- Multi-User: Login Flow v2 for per-user app password acquisition

Key changes:
- Use Nextcloud Login Flow v2 (NC 16+) for delegated authentication
- Application-level scope enforcement (app passwords have no native scopes)
- MCP elicitation for seamless authorization prompting
- Astrolabe front-end integration for scope management UI
- Clear security posture documentation for administrators

This removes the need for upstream Nextcloud OAuth patches and simplifies
deployment while maintaining security through defense-in-depth.

Related: #521

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 09:23:32 +01:00
github-actions[bot] af0b9c1f93 bump: version 0.57.58 → 0.57.59 2026-02-18 08:09:39 +00:00
Chris Coutinho 2d7360ebd7 Merge pull request #527 from cbcoutinho/renovate/actions-checkout-digest
chore(deps): update actions/checkout digest to de0fac2
2026-02-18 09:09:19 +01:00
github-actions[bot] 56542802bc bump: version 0.57.57 → 0.57.58 2026-02-18 07:55:29 +00:00
Chris Coutinho c03dbd1b55 Merge pull request #518 from cbcoutinho/renovate/docker.io-library-redis-alpine
chore(deps): update docker.io/library/redis:alpine docker digest to fd83658
2026-02-18 08:55:14 +01:00
github-actions[bot] 99925d9f22 bump: version 0.57.56 → 0.57.57 2026-02-18 07:49:54 +00:00
Chris Coutinho 0dfaf954d7 Merge pull request #546 from cbcoutinho/renovate/docker.io-library-mariadb-lts
chore(deps): update docker.io/library/mariadb:lts docker digest to 8164f18
2026-02-18 08:49:36 +01:00
github-actions[bot] b3fe7099cb bump: version 0.57.55 → 0.57.56 2026-02-18 06:27:44 +00:00
Chris Coutinho 7152537fd4 Merge pull request #564 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.4
2026-02-18 07:27:28 +01:00
renovate-bot-cbcoutinho[bot] 9d31925f27 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.4 2026-02-17 23:15:29 +00:00
renovate-bot-cbcoutinho[bot] 3a322c34bc chore(deps): update docker.io/library/mariadb:lts docker digest to 8164f18 2026-02-17 23:15:17 +00:00
github-actions[bot] b1bd025aac bump: version 0.57.54 → 0.57.55 2026-02-17 13:45:28 +00:00
Chris Coutinho 8a1c604d78 Merge pull request #499 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.3
2026-02-17 14:45:10 +01:00
github-actions[bot] 3616dee54c bump: version 0.57.53 → 0.57.54 2026-02-17 04:38:16 +00:00
Chris Coutinho dbb36a7b63 Merge pull request #550 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.3
2026-02-17 05:38:01 +01:00
github-actions[bot] f1797b2f8e bump: version 0.57.52 → 0.57.53 2026-02-17 04:37:51 +00:00
Chris Coutinho 1d5d4f86d7 Merge pull request #429 from cbcoutinho/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6.0.2
2026-02-17 05:37:36 +01:00
github-actions[bot] 44030805f1 bump: version 0.57.51 → 0.57.52 2026-02-17 04:37:17 +00:00
Chris Coutinho afd7e69f76 Merge pull request #561 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.53
2026-02-17 05:37:01 +01:00
renovate-bot-cbcoutinho[bot] 31be72ae24 chore(deps): update anthropics/claude-code-action action to v1.0.53 2026-02-16 23:10:46 +00:00
github-actions[bot] 6bd05a81bf bump: version 0.57.50 → 0.57.51 2026-02-16 14:49:23 +00:00
github-actions[bot] a4e3f0b354 bump: version 0.63.5 → 0.64.0 2026-02-16 14:49:23 +00:00
Chris Coutinho 0f23964752 Merge pull request #562 from cbcoutinho/feat/self-signed-ssl-support
feat: add self-signed SSL certificate support
2026-02-16 15:49:00 +01:00
Chris Coutinho 66ccacdee1 fix: add type: ignore for caldav ssl_verify_cert parameter
caldav types declare ssl_verify_cert as Union[bool, str] but the value
is passed through to httpx which accepts ssl.SSLContext. Add type: ignore
annotation to satisfy the ty type checker in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:34:05 +01:00
Chris Coutinho 1a4486a388 fix: convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
httpx emits a DeprecationWarning when verify=<str> is passed, recommending
ssl.SSLContext instead. This affected both our httpx client factories and
the caldav library passthrough.

Changed get_nextcloud_ssl_verify() to return bool | ssl.SSLContext instead
of bool | str by constructing an SSLContext when NEXTCLOUD_CA_BUNDLE is set.
All downstream consumers (httpx, caldav) natively accept ssl.SSLContext.

Also fixed app password endpoint tests that used overly broad MagicMock
(auto-generated truthy nextcloud_ca_bundle attribute).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:27:22 +01:00
github-actions[bot] 91d06acfb4 bump: version 0.57.49 → 0.57.50 2026-02-16 11:38:36 +00:00
Chris Coutinho 90874ca7cd Merge pull request #563 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.43.0
2026-02-16 12:37:47 +01:00
renovate-bot-cbcoutinho[bot] da8fed3382 chore(deps): update helm release ollama to v1.43.0 2026-02-16 11:16:48 +00:00
renovate-bot-cbcoutinho[bot] 8963e65f1b chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.3 2026-02-16 11:16:20 +00:00
renovate-bot-cbcoutinho[bot] 75c3868e74 chore(deps): update actions/checkout action to v6.0.2 2026-02-16 11:16:12 +00:00
Chris Coutinho 1707b2e6e1 feat: add self-signed SSL certificate support for Nextcloud connections
Add NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE env vars to configure
TLS certificate verification for all outbound Nextcloud connections.
Centralizes SSL config via a new HTTP client factory (http.py) used by
all 27 Nextcloud-bound call sites, including API clients, OIDC endpoints,
OAuth flows, and health checks.

Closes #560

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:21:21 +01:00
github-actions[bot] df3cce4370 bump: version 0.57.48 → 0.57.49 2026-02-16 07:20:08 +00:00
github-actions[bot] 1c5e21843e bump: version 0.63.4 → 0.63.5 2026-02-16 07:20:07 +00:00
Chris Coutinho 520ef113ba Merge pull request #549 from cbcoutinho/extract-astrolabe
Extract Astrolabe to separate repository
2026-02-16 08:19:48 +01:00
Chris Coutinho 3be229a487 refactor: remove stale astrolabe references from commitizen config
Remove astrolabe tag filtering and commit scope exclusions from
pyproject.toml now that Astrolabe lives in its own repository.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:17:38 +01:00
github-actions[bot] 6da69b0336 bump: version 0.57.47 → 0.57.48 2026-02-15 19:37:43 +00:00
Chris Coutinho 427e501691 Merge pull request #556 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.51
2026-02-15 20:37:28 +01:00
github-actions[bot] 9c275d1a3f bump: version 0.57.46 → 0.57.47 2026-02-15 19:17:18 +00:00
Chris Coutinho af43630ca7 Merge pull request #558 from cbcoutinho/renovate/docker.io-library-nextcloud-32.x
chore(deps): update docker.io/library/nextcloud docker tag to v32.0.6
2026-02-15 20:17:02 +01:00
Chris Coutinho 49c5439686 docs: annotate astrolabe integration tests as cross-system interface tests
Add cross-system interface test annotations to the 5 astrolabe test files,
clarifying they test the MCP server's integration with the Astrolabe
Nextcloud app (installed from the app store, source now in a separate repo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:54:08 +01:00
renovate-bot-cbcoutinho[bot] c5eec64716 chore(deps): update docker.io/library/nextcloud docker tag to v32.0.6 2026-02-15 11:10:51 +00:00
renovate-bot-cbcoutinho[bot] 3948f6a019 chore(deps): update anthropics/claude-code-action action to v1.0.51 2026-02-14 11:16:18 +00:00
Chris Coutinho 08d37a6597 docs: clean up astrolabe references after extraction
Remove astrolabe-specific docs and sections that belong in the
astrolabe repo. Update remaining references to point to the
astrolabe repo where appropriate.

- Fix .gitmodules SSH → HTTPS URL for astrolabe submodule
- Remove bump-version.yml stale "astrolabe" scope comment
- Delete blog-introducing-astrolabe.md (moved to astrolabe repo)
- Remove "Astrolabe Background Token Refresh" section from auth-flows.md
- Replace "Astrolabe User Setup" section in authentication.md with link
- Remove "Astrolabe Internal URL" section from configuration.md
- Remove "Webhook Presets (via Astrolabe UI)" from webhook guide

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:16:50 +01:00
github-actions[bot] 4712235390 bump: version 0.57.45 → 0.57.46 2026-02-12 22:01:48 +00:00
Chris Coutinho d0f18b36e8 Merge pull request #541 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.49
2026-02-12 23:01:30 +01:00
github-actions[bot] aca0d236b4 bump: version 0.57.44 → 0.57.45 2026-02-12 22:01:04 +00:00
Chris Coutinho 7ab0dcd3d8 Merge pull request #553 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 3b9280e
2026-02-12 23:00:48 +01:00
renovate-bot-cbcoutinho[bot] eafef986f2 chore(deps): update anthropics/claude-code-action action to v1.0.49 2026-02-12 11:14:09 +00:00
renovate-bot-cbcoutinho[bot] 8126beb16e chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to 3b9280e 2026-02-12 11:14:03 +00:00
renovate-bot-cbcoutinho[bot] bce6686494 chore(deps): update docker.io/library/redis:alpine docker digest to fd83658 2026-02-11 11:12:10 +00:00
Chris Coutinho dfc75a8619 refactor: extract Astrolabe to separate repository
Astrolabe has been extracted to its own repository at
github.com/cbcoutinho/astrolabe for independent releases.

Changes:
- Replace third_party/astrolabe/ directory with git submodule
- Remove astrolabe-ci.yml and appstore-build-publish.yml workflows
- Remove scripts/bump-astrolabe.sh
- Remove Astrolabe sections from bump-version.yml workflow
- Remove Astrolabe build steps from test.yml CI workflow
- Remove astrolabe volume mount from docker-compose.yml
- Simplify astrolabe install hook to always use app store
- Update CONTRIBUTING.md to reflect two-component monorepo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 10:10:29 +01:00
github-actions[bot] 254cb6cf06 bump: version 0.57.43 → 0.57.44 2026-02-11 07:00:16 +00:00
Chris Coutinho 940e7d3e4e Merge pull request #547 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 dcc82dc
2026-02-11 07:59:59 +01:00
github-actions[bot] ac985b265e bump: version 0.57.42 → 0.57.43 2026-02-11 06:21:43 +00:00
Chris Coutinho e7f452342e Merge pull request #548 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.42.0
2026-02-11 07:21:28 +01:00
renovate-bot-cbcoutinho[bot] e4b5617a55 chore(deps): update helm release ollama to v1.42.0 2026-02-10 11:10:51 +00:00
renovate-bot-cbcoutinho[bot] 291a13c064 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.3 2026-02-10 11:09:44 +00:00
renovate-bot-cbcoutinho[bot] 0e9fee5616 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to dcc82dc 2026-02-10 11:09:31 +00:00
github-actions[bot] 093f1d7302 bump: version 0.57.41 → 0.57.42 2026-02-08 22:52:21 +00:00
github-actions[bot] 9da5f95bcb bump: version 0.63.3 → 0.63.4 2026-02-08 22:52:20 +00:00
Chris Coutinho 1d4aede0f9 Merge pull request #545 from cbcoutinho/fix/update-event-extended-fields
fix: handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
2026-02-08 23:52:01 +01:00
Chris Coutinho ec8eab99f3 fix: strip whitespace from category names when splitting
Trim whitespace from comma-separated category values in all three
methods: _create_ical_event, _merge_ical_properties, and
_merge_ical_todo_properties. Prevents leading/trailing spaces in
category names from inputs like "work, meeting".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 23:46:28 +01:00
Chris Coutinho da104c59ac fix: handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
_merge_ical_properties() only handled a subset of event fields, silently
dropping categories, recurrence_rule, attendees, and reminder_minutes
during updates. These fields were fully supported by _create_ical_event()
and accepted by the MCP tool, but never applied.

Closes #544

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 23:15:37 +01:00
github-actions[bot] b3e55d444b bump: version 0.57.40 → 0.57.41 2026-02-08 12:57:42 +00:00
github-actions[bot] 1786e204ec bump: version 0.63.2 → 0.63.3 2026-02-08 12:57:41 +00:00
Chris Coutinho 0a599c5c03 Merge pull request #543 from cbcoutinho/fix/recurring-event-expansion
fix: expand recurring events in date-range queries
2026-02-08 13:57:22 +01:00
Chris Coutinho 66e32d4705 fix: expand recurring events in date-range queries
PR #539 fixed date-range filtering so events outside the queried range
are excluded. However, recurring events still returned the master event
with its original DTSTART instead of expanded occurrences.

Add <C:expand> element to CalDAV REPORT requests (RFC 4791 §9.6.5) when
both date bounds are provided, so the server returns one VEVENT per
occurrence with the correct DTSTART. Refactor VEVENT parsing into a
shared helper and add _parse_all_ical_events() to handle multi-VEVENT
responses from expanded results.

Closes #538

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:43:40 +01:00
github-actions[bot] 8603ed114e bump: version 0.57.39 → 0.57.40 2026-02-07 16:38:25 +00:00
github-actions[bot] 7e6ef90423 bump: version 0.63.1 → 0.63.2 2026-02-07 16:38:24 +00:00
Chris Coutinho c5f2c8369f Merge pull request #539 from cbcoutinho/fix/calendar-date-range-filtering
fix: use CalDAV time-range filter for calendar date range queries
2026-02-07 17:38:05 +01:00
Chris Coutinho b79ac29a9d fix: use CalDAV time-range filter for calendar date range queries
get_calendar_events() accepted start/end datetime parameters but called
calendar.events() which fetches all events, silently discarding the
date filters. This caused nc_calendar_list_events and
nc_calendar_get_upcoming_events to return the entire calendar history.

Add _search_events_by_date() helper that builds a CalDAV REPORT query
with a <time-range> filter (RFC 4791 §9.9) for server-side filtering.
Falls back to calendar.events() when no dates are given.

Closes #538

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:35:33 +01:00
github-actions[bot] 334d62825c bump: version 0.57.38 → 0.57.39 2026-02-07 14:49:39 +00:00
Chris Coutinho 2233cb423c Merge pull request #537 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 9e01bf1
2026-02-07 15:49:23 +01:00
github-actions[bot] 196a6cdfb2 bump: version 0.57.37 → 0.57.38 2026-02-07 14:47:36 +00:00
Chris Coutinho 93f5e70128 Merge pull request #519 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.3.0
2026-02-07 15:47:20 +01:00
renovate-bot-cbcoutinho[bot] e5248e70ee chore(deps): update astral-sh/setup-uv action to v7.3.0 2026-02-07 11:10:43 +00:00
renovate-bot-cbcoutinho[bot] 018b946b5b chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 9e01bf1 2026-02-07 11:10:27 +00:00
github-actions[bot] 863ba0d52a bump: version 0.57.36 → 0.57.37 2026-02-06 20:08:06 +00:00
Chris Coutinho d3903c5e2e Merge pull request #529 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.41.0
2026-02-06 21:07:48 +01:00
github-actions[bot] 6ea97c5b88 bump: version 0.57.35 → 0.57.36 2026-02-06 17:44:50 +00:00
Chris Coutinho c12c825b11 Merge pull request #530 from cbcoutinho/renovate/hoverkraft-tech-compose-action-2.x
chore(deps): update hoverkraft-tech/compose-action action to v2.5.0
2026-02-06 18:44:32 +01:00
github-actions[bot] 3d8f7692a8 bump: version 0.57.34 → 0.57.35 2026-02-06 15:18:18 +00:00
Chris Coutinho b21c874c14 Merge pull request #531 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 5878d06
2026-02-06 16:18:00 +01:00
github-actions[bot] a4661099e5 bump: version 0.57.33 → 0.57.34 2026-02-06 14:49:36 +00:00
Chris Coutinho a46d74d999 Merge pull request #522 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.45
2026-02-06 15:49:19 +01:00
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
renovate-bot-cbcoutinho[bot] 14c4512ef8 chore(deps): update anthropics/claude-code-action action to v1.0.45 2026-02-06 11:08:46 +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
renovate-bot-cbcoutinho[bot] 5eeaafbe95 chore(deps): update docker.io/library/nginx:alpine docker digest to 5878d06 2026-02-05 11:11:43 +00:00
renovate-bot-cbcoutinho[bot] 0ddc62c371 chore(deps): update hoverkraft-tech/compose-action action to v2.5.0 2026-02-04 11:09:33 +00:00
renovate-bot-cbcoutinho[bot] 36d901d5ae chore(deps): update helm release ollama to v1.41.0 2026-02-04 11:09:28 +00:00
renovate-bot-cbcoutinho[bot] 119a422a35 chore(deps): update actions/checkout digest to de0fac2 2026-02-04 11:08:12 +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
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
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
194 changed files with 10993 additions and 29011 deletions
@@ -1,89 +0,0 @@
name: Build and Publish Astrolabe App Release
on:
push:
tags:
- 'astrolabe-v*'
env:
APP_NAME: astrolabe
APP_DIR: third_party/astrolabe
jobs:
build-and-publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Get version from tag
id: tag
run: |
echo "TAG=${GITHUB_REF#refs/tags/astrolabe-v}" >> $GITHUB_OUTPUT
- name: Validate version in info.xml matches tag
working-directory: ${{ env.APP_DIR }}
run: |
INFO_VERSION=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' appinfo/info.xml | tr -d '\t')
if [ "$INFO_VERSION" != "${{ steps.tag.outputs.TAG }}" ]; then
echo "Version mismatch: info.xml has $INFO_VERSION but tag is ${{ steps.tag.outputs.TAG }}"
exit 1
fi
echo "Version validated: $INFO_VERSION"
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Setup PHP
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
with:
php-version: 8.1
coverage: none
- name: Checkout Nextcloud server (for signing)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: nextcloud/server
ref: stable30
path: server
- name: Install dependencies and build
working-directory: ${{ env.APP_DIR }}
run: |
composer install --no-dev --optimize-autoloader
npm ci
npm run build
- name: Setup signing certificate
run: |
mkdir -p $HOME/.nextcloud/certificates
echo "${{ secrets.APP_PRIVATE_KEY }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.key
echo "${{ secrets.APP_PUBLIC_CRT }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.crt
- name: Build app store package
working-directory: ${{ env.APP_DIR }}
run: make appstore server_dir=${{ github.workspace }}/server
- name: Create GitHub release and attach tarball
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ env.APP_DIR }}/build/artifacts/${{ env.APP_NAME }}.tar.gz
asset_name: ${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
tag: ${{ github.ref }}
release_name: Astrolabe ${{ steps.tag.outputs.TAG }}
prerelease: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
- name: Upload to Nextcloud App Store
uses: R0Wi/nextcloud-appstore-push-action@9244bb5445776688cfe90fa1903ea8dff95b0c28 # v1.0.4
with:
app_name: ${{ env.APP_NAME }}
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
download_url: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
nightly: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
-275
View File
@@ -1,275 +0,0 @@
# Consolidated CI workflow for Astroglobe Nextcloud app
#
# Runs on PRs that modify the astroglobe directory
# Based on Nextcloud app skeleton workflows
#
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
# SPDX-License-Identifier: MIT
name: Astroglobe CI
on:
pull_request:
paths:
- 'third_party/astroglobe/**'
- '.github/workflows/astroglobe-ci.yml'
permissions:
contents: read
concurrency:
group: astroglobe-ci-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
php: ${{ steps.changes.outputs.php }}
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
continue-on-error: true
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'
php:
- 'third_party/astroglobe/lib/**'
- 'third_party/astroglobe/appinfo/**'
- 'third_party/astroglobe/composer.json'
- 'third_party/astroglobe/psalm.xml'
# Node.js build and lint
node-build:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.frontend != 'false'
name: Node.js build
defaults:
run:
working-directory: third_party/astroglobe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
path: third_party/astroglobe
fallbackNode: '^20'
fallbackNpm: '^10'
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ steps.versions.outputs.nodeVersion }}
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
- name: Install dependencies & build
env:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_DOWNLOAD: true
run: |
npm ci
npm run build --if-present
- name: Check webpack build changes
run: |
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please recompile and commit the assets' && exit 1)"
# ESLint
eslint:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.frontend != 'false'
name: ESLint
defaults:
run:
working-directory: third_party/astroglobe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
path: third_party/astroglobe
fallbackNode: '^20'
fallbackNpm: '^10'
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ steps.versions.outputs.nodeVersion }}
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
- name: Install dependencies
env:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_DOWNLOAD: true
run: npm ci
- name: Lint
run: npm run lint
# Stylelint
stylelint:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.frontend != 'false'
name: Stylelint
defaults:
run:
working-directory: third_party/astroglobe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
path: third_party/astroglobe
fallbackNode: '^20'
fallbackNpm: '^10'
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ steps.versions.outputs.nodeVersion }}
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
- name: Install dependencies
env:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_DOWNLOAD: true
run: npm ci
- name: Lint
run: npm run stylelint
# PHP Code Style
php-cs:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.php != 'false'
name: PHP CS Fixer
defaults:
run:
working-directory: third_party/astroglobe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get php version
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: third_party/astroglobe/appinfo/info.xml
- name: Set up php${{ steps.versions.outputs.php-min }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ steps.versions.outputs.php-min }}
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
coverage: none
ini-file: development
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: |
composer remove nextcloud/ocp --dev || true
composer i
- name: Lint
run: composer run cs:check || ( echo 'Please run `composer run cs:fix` to format your code' && exit 1 )
# Psalm Static Analysis
psalm:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.php != 'false'
name: Psalm
defaults:
run:
working-directory: third_party/astroglobe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get php version
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: third_party/astroglobe/appinfo/info.xml
- name: Set up php${{ steps.versions.outputs.php-min }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ steps.versions.outputs.php-min }}
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
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/astroglobe/appinfo/info.xml
- name: Install OCP for static analysis
run: |
# Get first OCP version from matrix
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 Psalm
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
# Summary job
summary:
permissions:
contents: none
runs-on: ubuntu-latest
needs: [changes, node-build, eslint, stylelint, php-cs, psalm]
if: always()
name: astroglobe-ci-summary
steps:
- name: Summary status
run: |
if ${{ needs.changes.outputs.frontend != 'false' && (needs.node-build.result != 'success' || needs.eslint.result != 'success' || needs.stylelint.result != 'success') }}; then
echo "Frontend checks failed"
exit 1
fi
if ${{ needs.changes.outputs.php != 'false' && (needs.php-cs.result != 'success' || needs.psalm.result != 'success') }}; then
echo "PHP checks failed"
exit 1
fi
echo "All checks passed"
+18 -19
View File
@@ -15,13 +15,13 @@ jobs:
packages: write
steps:
- name: Check out
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
@@ -71,7 +71,7 @@ jobs:
fi
}
# Bump MCP server (default - all commits except helm/astrolabe scopes)
# Bump MCP server (default - all commits except helm scope)
echo "Checking MCP server for version bump..."
# Get the most recent MCP tag
@@ -83,33 +83,36 @@ jobs:
commit_range="${last_mcp_tag}..HEAD"
fi
# Count conventional commits that are NOT scoped to helm or astrolabe
# Count conventional commits that are NOT scoped to helm
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)
{ grep -v "(helm)" || 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..."
./scripts/bump-helm.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
HELM_HAS_COMMITS=true
fi
# Bump Astrolabe (scope: astrolabe)
echo "Checking Astrolabe for version bump..."
if has_commits_since_tag "astrolabe-v" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(astrolabe\)(!)?:"; then
echo "Bumping Astrolabe version..."
./scripts/bump-astrolabe.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS astrolabe"
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
# Output summary
@@ -147,10 +150,6 @@ jobs:
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
astrolabe)
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
esac
done
+3 -2
View File
@@ -27,15 +27,16 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
uses: anthropics/claude-code-action@64c7a0ef71df67b14cb4471f4d9c8565c61042bf # v1.0.66
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 }}
+2 -2
View File
@@ -26,13 +26,13 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
uses: anthropics/claude-code-action@64c7a0ef71df67b14cb4471f4d9c8565c61042bf # v1.0.66
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+5 -5
View File
@@ -13,11 +13,11 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
# list of Docker images to use as base name for tags
images: |
@@ -34,18 +34,18 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
+4 -4
View File
@@ -24,10 +24,10 @@ jobs:
models: read
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run docker compose with vector sync
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
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@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
- name: Wait for Nextcloud to be ready
run: |
@@ -97,7 +97,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: rag-evaluation-results
path: |
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
+6 -23
View File
@@ -9,9 +9,9 @@ jobs:
linting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: 'true'
@@ -35,7 +35,7 @@ jobs:
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
with:
php-version: 8.4
coverage: none
@@ -48,32 +48,15 @@ jobs:
###### Required to build OIDC App ######
###### Required to build Astrolabe App ######
- name: Set up Node.js for Astrolabe
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '20'
- name: Build Astrolabe app
run: |
cd third_party/astrolabe
composer install --no-dev --optimize-autoloader
npm ci
npm run build
###### Required to build Astrolabe App ######
- name: Run docker compose
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
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@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
- name: Install Playwright dependencies
run: |
+3
View File
@@ -4,3 +4,6 @@
[submodule "third_party/notes"]
path = third_party/notes
url = https://github.com/cbcoutinho/notes
[submodule "third_party/astrolabe"]
path = third_party/astrolabe
url = https://github.com/cbcoutinho/astrolabe
+188
View File
@@ -5,6 +5,194 @@ 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.64.5 (2026-03-03)
### Fix
- handle pythonvCard4 dict-format fields and missing phone numbers (#601)
## v0.64.4 (2026-02-26)
### Fix
- **deps**: update dependency icalendar to v7
## v0.64.3 (2026-02-21)
### Fix
- address PR #574 fourth review round
- address PR #574 third review round
- address PR #574 second review round
- address PR #574 review comments
- wrap raw list returns in response models to produce single TextContent block
## v0.64.2 (2026-02-20)
### Fix
- address PR #571 review comments
- resolve stale credentials causing astrolabe background sync test failures
### Refactor
- enforce PLC0415 (import-outside-top-level) for source code
## v0.64.1 (2026-02-18)
### Fix
- **deps**: update dependency mcp to >=1.26,<1.27
## v0.64.0 (2026-02-16)
### Feat
- add self-signed SSL certificate support for Nextcloud connections
### Fix
- add type: ignore for caldav ssl_verify_cert parameter
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
## v0.63.5 (2026-02-16)
### Refactor
- remove stale astrolabe references from commitizen config
- extract Astrolabe to separate repository
## v0.63.4 (2026-02-08)
### Fix
- strip whitespace from category names when splitting
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
## v0.63.3 (2026-02-08)
### Fix
- expand recurring events in date-range queries
## v0.63.2 (2026-02-07)
### Fix
- use CalDAV time-range filter for calendar date range queries
## 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
+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:**
+3 -13
View File
@@ -2,7 +2,7 @@
## Version Management
This monorepo uses commitizen for version management with **independent versioning** for three components:
This monorepo uses commitizen for version management with **independent versioning** for two components:
### Components
@@ -10,7 +10,8 @@ This monorepo uses commitizen for version management with **independent versioni
|-----------|-------|--------------|-------------|
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
| Astrolabe App | `astrolabe` | `./scripts/bump-astrolabe.sh` | `astrolabe-v0.2.0` |
> **Note:** The Astrolabe Nextcloud app has been moved to its own repository at [cbcoutinho/astrolabe](https://github.com/cbcoutinho/astrolabe).
### Commit Message Format
@@ -24,10 +25,6 @@ fix(mcp): resolve authentication bug
# Helm chart changes
feat(helm): add resource limits
docs(helm): update values documentation
# Astrolabe app changes
feat(astrolabe): add dark mode toggle
fix(astrolabe): resolve search UI bug
```
**Unscoped commits** default to the MCP server:
@@ -40,7 +37,6 @@ feat: add new feature # → MCP server (v0.54.0)
#### 1. Make Changes with Scoped Commits
```bash
git commit -m "feat(astrolabe): add dark mode toggle"
git commit -m "feat(helm): add ingress annotations"
git commit -m "feat(mcp): add calendar sync"
```
@@ -58,10 +54,6 @@ git commit -m "feat(mcp): add calendar sync"
# → Creates tag: nextcloud-mcp-server-0.54.0
# → Updates: Chart.yaml:version
# Bump Astrolabe (reads commits with scope=astrolabe)
./scripts/bump-astrolabe.sh
# → Creates tag: astrolabe-v0.2.0
# → Updates: info.xml, package.json
```
#### 3. Push Tags
@@ -76,7 +68,6 @@ Each component maintains its own `CHANGELOG.md`:
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
- **Astrolabe**: `third_party/astrolabe/CHANGELOG.md` - includes `feat(astrolabe):` only
### Manual Version Bumps
@@ -101,7 +92,6 @@ uv run cz --config .cz.toml bump --increment MINOR
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
- **Astrolabe**: Follows PEP 440, `major_version_zero = true` (0.x.x for alpha/beta)
### Chart.yaml Version vs appVersion
+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:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /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:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
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.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
@@ -2,35 +2,17 @@
set -euox pipefail
echo "Installing Astrolabe app for testing..."
echo "Installing Astrolabe app from app store..."
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
if [ -d /opt/apps/astrolabe ]; then
echo "Development astrolabe app found at /opt/apps/astrolabe"
# Remove any existing astrolabe app in custom_apps (from app store or old symlink)
if [ -e /var/www/html/custom_apps/astrolabe ]; then
echo "Removing existing astrolabe in custom_apps..."
rm -rf /var/www/html/custom_apps/astrolabe
fi
# Create symlink from custom_apps to the mounted development version
# Per Nextcloud docs: apps outside server root need symlinks in server root
echo "Creating symlink: custom_apps/astrolabe -> /opt/apps/astrolabe"
ln -sf /opt/apps/astrolabe /var/www/html/custom_apps/astrolabe
echo "Enabling astrolabe app from /opt/apps (development mode via symlink)"
php /var/www/html/occ app:enable astrolabe
elif [ -d /var/www/html/custom_apps/astrolabe ]; then
if [ -d /var/www/html/custom_apps/astrolabe ]; then
echo "astrolabe app directory found in custom_apps (already installed)"
php /var/www/html/occ app:enable astrolabe
else
echo "astrolabe app not found, installing from app store..."
php /var/www/html/occ app:install astrolabe
php /var/www/html/occ app:enable astrolabe
fi
echo "Astrolabe app installed successfully"
echo "Astrolabe app installed successfully"
echo ""
echo "Note: MCP server configuration is managed dynamically during tests"
echo " to support testing multiple MCP server deployments."
+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.56.2"
version = "0.57.93"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
+321
View File
@@ -14,6 +14,327 @@ 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.93 (2026-03-03)
## nextcloud-mcp-server-0.57.92 (2026-03-02)
## nextcloud-mcp-server-0.57.91 (2026-03-02)
## nextcloud-mcp-server-0.57.90 (2026-03-01)
## nextcloud-mcp-server-0.57.89 (2026-03-01)
## nextcloud-mcp-server-0.57.88 (2026-03-01)
## nextcloud-mcp-server-0.57.87 (2026-03-01)
## nextcloud-mcp-server-0.57.86 (2026-02-26)
### Fix
- **deps**: update dependency icalendar to v7
## nextcloud-mcp-server-0.57.85 (2026-02-25)
## nextcloud-mcp-server-0.57.84 (2026-02-25)
## nextcloud-mcp-server-0.57.83 (2026-02-25)
## nextcloud-mcp-server-0.57.82 (2026-02-25)
## nextcloud-mcp-server-0.57.81 (2026-02-25)
## nextcloud-mcp-server-0.57.80 (2026-02-24)
## nextcloud-mcp-server-0.57.79 (2026-02-24)
## nextcloud-mcp-server-0.57.78 (2026-02-24)
## nextcloud-mcp-server-0.57.77 (2026-02-24)
## nextcloud-mcp-server-0.57.76 (2026-02-24)
## nextcloud-mcp-server-0.57.75 (2026-02-23)
## nextcloud-mcp-server-0.57.74 (2026-02-21)
## nextcloud-mcp-server-0.57.73 (2026-02-21)
### Fix
- address PR #574 fourth review round
- address PR #574 third review round
- address PR #574 second review round
- address PR #574 review comments
- wrap raw list returns in response models to produce single TextContent block
## nextcloud-mcp-server-0.57.72 (2026-02-20)
## nextcloud-mcp-server-0.57.71 (2026-02-20)
## nextcloud-mcp-server-0.57.70 (2026-02-20)
### Fix
- address PR #571 review comments
- resolve stale credentials causing astrolabe background sync test failures
### Refactor
- enforce PLC0415 (import-outside-top-level) for source code
## nextcloud-mcp-server-0.57.69 (2026-02-20)
## nextcloud-mcp-server-0.57.68 (2026-02-19)
## nextcloud-mcp-server-0.57.67 (2026-02-19)
## nextcloud-mcp-server-0.57.66 (2026-02-18)
## nextcloud-mcp-server-0.57.65 (2026-02-18)
## nextcloud-mcp-server-0.57.64 (2026-02-18)
## nextcloud-mcp-server-0.57.63 (2026-02-18)
## nextcloud-mcp-server-0.57.62 (2026-02-18)
### Fix
- **deps**: update dependency mcp to >=1.26,<1.27
## nextcloud-mcp-server-0.57.61 (2026-02-18)
## nextcloud-mcp-server-0.57.60 (2026-02-18)
## nextcloud-mcp-server-0.57.59 (2026-02-18)
## nextcloud-mcp-server-0.57.58 (2026-02-18)
## nextcloud-mcp-server-0.57.57 (2026-02-18)
## nextcloud-mcp-server-0.57.56 (2026-02-18)
## nextcloud-mcp-server-0.57.55 (2026-02-17)
## nextcloud-mcp-server-0.57.54 (2026-02-17)
## nextcloud-mcp-server-0.57.53 (2026-02-17)
## nextcloud-mcp-server-0.57.52 (2026-02-17)
## nextcloud-mcp-server-0.57.51 (2026-02-16)
### Feat
- add self-signed SSL certificate support for Nextcloud connections
### Fix
- add type: ignore for caldav ssl_verify_cert parameter
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
## nextcloud-mcp-server-0.57.50 (2026-02-16)
## nextcloud-mcp-server-0.57.49 (2026-02-16)
### Refactor
- remove stale astrolabe references from commitizen config
- extract Astrolabe to separate repository
## nextcloud-mcp-server-0.57.48 (2026-02-15)
## nextcloud-mcp-server-0.57.47 (2026-02-15)
## nextcloud-mcp-server-0.57.46 (2026-02-12)
## nextcloud-mcp-server-0.57.45 (2026-02-12)
## nextcloud-mcp-server-0.57.44 (2026-02-11)
## nextcloud-mcp-server-0.57.43 (2026-02-11)
## nextcloud-mcp-server-0.57.42 (2026-02-08)
### Fix
- strip whitespace from category names when splitting
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
## nextcloud-mcp-server-0.57.41 (2026-02-08)
### Fix
- expand recurring events in date-range queries
## nextcloud-mcp-server-0.57.40 (2026-02-07)
### Fix
- use CalDAV time-range filter for calendar date range queries
## nextcloud-mcp-server-0.57.39 (2026-02-07)
## nextcloud-mcp-server-0.57.38 (2026-02-07)
## nextcloud-mcp-server-0.57.37 (2026-02-06)
## nextcloud-mcp-server-0.57.36 (2026-02-06)
## nextcloud-mcp-server-0.57.35 (2026-02-06)
## nextcloud-mcp-server-0.57.34 (2026-02-06)
## 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
+4 -4
View File
@@ -1,9 +1,9 @@
dependencies:
- name: qdrant
repository: https://qdrant.github.io/qdrant-helm
version: 1.16.3
version: 1.17.0
- 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.47.0
digest: sha256:08d589dd1b3386e8e8a2ac2c03a2194218ab12ed9e02016e7b981e554385dd11
generated: "2026-03-02T11:15:27.688786078Z"
+4 -4
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.56.2
appVersion: "0.60.2"
version: 0.57.93
appVersion: "0.64.5"
keywords:
- nextcloud
- mcp
@@ -27,10 +27,10 @@ annotations:
grafana_dashboard_folder: "Nextcloud MCP"
dependencies:
- name: qdrant
version: "1.16.3"
version: "1.17.0"
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
version: "1.36.0"
version: "1.47.0"
repository: https://otwld.github.io/ollama-helm
condition: ollama.enabled
+19
View File
@@ -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 |
@@ -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
*/}}
@@ -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 }}
+21
View File
@@ -139,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)
+12 -17
View File
@@ -3,7 +3,7 @@ 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:8164f184d16c30e2f159e30518113667b796306dff0fe558876ab1ff521a682f
restart: always
command: --transaction-isolation=READ-COMMITTED
volumes:
@@ -19,11 +19,11 @@ 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:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0
restart: always
app:
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
image: docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267
restart: always
ports:
- 127.0.0.1:8080:80
@@ -37,7 +37,6 @@ services:
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
#- ./third_party:/opt/apps:ro
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -54,14 +53,14 @@ services:
retries: 30
recipes:
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
image: docker.io/library/nginx:alpine@sha256:5878d06ae4c83d73285438255f705bb3f9a736f41cd24876ed25bb33faf76c7d
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:ba6cb073af079c498e9466a5a9152ba4b6c9cad12efeeaf053ba383023d5db08
restart: always
ports:
- 127.0.0.1:8002:8000
@@ -88,8 +87,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
@@ -140,14 +139,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
@@ -180,7 +178,6 @@ 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
@@ -189,9 +186,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)
# Semantic search configuration (ADR-007, ADR-021)
- ENABLE_SEMANTIC_SEARCH=true
#- VECTOR_SYNC_ENABLED=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -211,7 +207,7 @@ services:
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
command:
- "start-dev"
- "--import-realm"
@@ -259,7 +255,6 @@ 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
@@ -293,13 +288,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.17.0@sha256:f1c7272cdac52b38c1a0e89313922d940ba50afd90d593a1605dbbc214e66ffb
restart: always
ports:
- 127.0.0.1:6333:6333 # REST API
File diff suppressed because it is too large Load Diff
+422
View File
@@ -0,0 +1,422 @@
# 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.
---
## 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
+4
View File
@@ -223,6 +223,10 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
| Token Storage | None | Refresh tokens only | All tokens |
| Deployment Complexity | Low | Medium | High |
### Astrolabe User Setup (Hybrid Mode)
For Astrolabe-specific user setup instructions in hybrid mode, see the [Astrolabe documentation](https://github.com/cbcoutinho/astrolabe/blob/master/docs/user-setup-hybrid-mode.md).
### See Also
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
- [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration
+52
View File
@@ -171,6 +171,58 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
---
## SSL/TLS Configuration (Optional)
If your Nextcloud instance uses a self-signed certificate or a private CA (common with reverse proxies like Traefik or Caddy), the MCP server will reject the connection by default. Use these settings to configure certificate verification.
### Custom CA Bundle (Recommended)
Point the server at your CA certificate file:
```dotenv
NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
```
With Docker, mount the certificate as a read-only volume:
```bash
docker run \
-v /path/to/my-ca.pem:/etc/ssl/certs/my-ca.pem:ro \
-e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem \
-e NEXTCLOUD_HOST=https://nextcloud.local \
--env-file .env \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Disable Verification (Development Only)
> [!WARNING]
> Disabling TLS verification is insecure. Only use this for local development or testing.
```dotenv
NEXTCLOUD_VERIFY_SSL=false
```
### Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NEXTCLOUD_VERIFY_SSL` | ⚠️ Optional | `true` | Set to `false` to disable TLS certificate verification |
| `NEXTCLOUD_CA_BUNDLE` | ⚠️ Optional | - | Path to a PEM CA bundle file for custom certificate authorities |
### Scope
These settings apply to **all** outbound connections to Nextcloud and its OIDC endpoints, including:
- Nextcloud API calls (Notes, Calendar, Contacts, WebDAV, etc.)
- OIDC discovery and token endpoints
- OAuth client registration (DCR)
- Health checks
They do **not** affect connections to internal services (Ollama, Qdrant, Unstructured) which have their own SSL configuration.
---
## Semantic Search Configuration (Optional)
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
+339
View File
@@ -0,0 +1,339 @@
# 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](https://github.com/cbcoutinho/astrolabe)** 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"
```
## 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 stored by Astrolabe app
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
```
+13
View File
@@ -217,6 +217,19 @@ NEXTCLOUD_PASSWORD=
#CUSTOM_PROCESSOR_TIMEOUT=60
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
# ===== SSL/TLS =====
# For Nextcloud behind reverse proxies with self-signed or private CA certificates
#
# Disable TLS certificate verification (insecure, development only):
#NEXTCLOUD_VERIFY_SSL=false
#
# Use a custom CA bundle (path to PEM file):
#NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
#
# Docker example: mount the CA bundle as a volume
# docker run -v /path/to/ca.pem:/etc/ssl/certs/my-ca.pem:ro \
# -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem ...
# ===== SECURITY & ADVANCED =====
# Cookie security (browser UI)
# Auto-detects from NEXTCLOUD_HOST protocol if not set
@@ -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",
]
+19 -923
View File
@@ -4,10 +4,15 @@ ADR-018: Provides REST API endpoints for the Nextcloud PHP app to query:
- Server status and version
- User session information and background access status
- Vector sync metrics
- Vector search for visualization
All endpoints use OAuth bearer token authentication via UnifiedTokenVerifier.
The PHP app obtains tokens through PKCE flow and uses them to access these endpoints.
Shared helper functions for other API modules are also exported from here:
- extract_bearer_token: Extract OAuth token from request
- validate_token_and_get_user: Validate token and get user ID
- _sanitize_error_for_client: Return safe error messages
- _parse_int_param, _parse_float_param, _validate_query_string: Parameter validation
"""
import logging
@@ -15,10 +20,15 @@ import time
from importlib.metadata import version
from typing import Any
import httpx
from qdrant_client.models import Filter
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.config_validators import AuthMode, detect_auth_mode
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__)
@@ -192,16 +202,12 @@ async def get_server_status(request: Request) -> JSONResponse:
# Public endpoint - no authentication required
# Get configuration
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
# Calculate uptime
uptime_seconds = int(time.time() - _server_start_time)
# Determine auth mode using proper mode detection
from nextcloud_mcp_server.config_validators import AuthMode, detect_auth_mode
mode = detect_auth_mode(settings)
# Map deployment mode to auth_mode for API response
@@ -229,8 +235,13 @@ async def get_server_status(request: Request) -> JSONResponse:
if mode == AuthMode.MULTI_USER_BASIC:
response_data["supports_app_passwords"] = settings.enable_offline_access
# Include OIDC configuration if in OAuth mode
if auth_mode == "oauth":
# Include OIDC configuration if OAuth is available
# This includes OAuth mode AND hybrid mode (multi_user_basic + offline_access)
# Astrolabe needs OIDC config to discover IdP for OAuth flow in hybrid mode
oauth_provisioning_available = auth_mode == "oauth" or (
mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access
)
if oauth_provisioning_available:
# Provide IdP discovery information for NC PHP app
oidc_config = {}
@@ -257,8 +268,6 @@ async def get_vector_sync_status(request: Request) -> JSONResponse:
"""
# Public endpoint - no authentication required
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
@@ -290,11 +299,6 @@ async def get_vector_sync_status(request: Request) -> JSONResponse:
# Get Qdrant client and query indexed count
indexed_count = 0
try:
from qdrant_client.models import Filter
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
# Count documents in collection, excluding placeholders
@@ -366,8 +370,6 @@ async def get_user_session(request: Request) -> JSONResponse:
# Check if offline access is enabled
# 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
@@ -508,909 +510,3 @@ async def revoke_user_access(request: Request) -> JSONResponse:
{"success": False, "error": error_msg},
status_code=500,
)
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,
)
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,
)
+427
View File
@@ -0,0 +1,427 @@
"""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
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_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
"""
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
"""
# 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 nextcloud_httpx_client(
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.
"""
# 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 nextcloud_httpx_client(
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,
)
+776
View File
@@ -0,0 +1,776 @@
"""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 Any
import pymupdf
from qdrant_client.models import FieldCondition, Filter, MatchValue
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,
)
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding.service import get_embedding_service
from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
)
from nextcloud_mcp_server.search.context import get_chunk_with_context
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
from nextcloud_mcp_server.vector.visualization import compute_pca_coordinates
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.
"""
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"
# 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:
if search_algo.query_embedding is not None:
query_embedding = search_algo.query_embedding
else:
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.
"""
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"
# 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:
# Get query embedding from search algorithm or generate it
if search_algo.query_embedding is not None:
query_embedding = search_algo.query_embedding
else:
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
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:
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
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,
)
+304
View File
@@ -0,0 +1,304 @@
"""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
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,
)
from nextcloud_mcp_server.client.webhooks import WebhooksClient
from ..http import nextcloud_httpx_client
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 nextcloud_httpx_client(
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:
# 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 nextcloud_httpx_client(
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:
# 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 nextcloud_httpx_client(
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:
# 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 nextcloud_httpx_client(
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,
)
+107 -95
View File
@@ -10,22 +10,17 @@ from collections.abc import AsyncIterator
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
from typing import Optional, cast
from urllib.parse import parse_qs, urlparse
import anyio
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
if TYPE_CHECKING:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
import click
import httpx
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.middleware.authentication import AuthenticationMiddleware
@@ -36,6 +31,23 @@ from starlette.staticfiles import StaticFiles
from starlette.types import ASGIApp, Receive, Send
from starlette.types import Scope as StarletteScope
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,
)
from nextcloud_mcp_server.auth import (
InsufficientScopeError,
discover_all_scopes,
@@ -43,7 +55,38 @@ from nextcloud_mcp_server.auth import (
has_required_scopes,
is_jwt_token,
)
from nextcloud_mcp_server.auth.browser_oauth_routes import (
oauth_login,
oauth_login_callback,
oauth_logout,
)
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
from nextcloud_mcp_server.auth.oauth_routes import (
oauth_authorize,
oauth_authorize_nextcloud,
oauth_callback,
oauth_callback_nextcloud,
)
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
from nextcloud_mcp_server.auth.userinfo_routes import (
revoke_session,
user_info_html,
vector_sync_status_fragment,
)
from nextcloud_mcp_server.auth.viz_routes import (
chunk_context_endpoint,
vector_visualization_html,
vector_visualization_search,
)
from nextcloud_mcp_server.auth.webhook_routes import (
disable_webhook_preset,
enable_webhook_preset,
webhook_management_pane,
)
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import (
DeploymentMode,
@@ -58,6 +101,7 @@ from nextcloud_mcp_server.config_validators import (
)
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
from nextcloud_mcp_server.document_processors import get_registry
from nextcloud_mcp_server.http import nextcloud_httpx_client
from nextcloud_mcp_server.observability import (
ObservabilityMiddleware,
setup_metrics,
@@ -81,6 +125,11 @@ from nextcloud_mcp_server.server import (
)
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
from nextcloud_mcp_server.vector import processor_task, scanner_task
from nextcloud_mcp_server.vector.oauth_sync import (
oauth_processor_task,
user_manager_task,
)
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
HTTPXClientInstrumentor().instrument()
@@ -105,7 +154,7 @@ def initialize_document_processors():
if "unstructured" in config["processors"]:
unst_config = config["processors"]["unstructured"]
try:
from nextcloud_mcp_server.document_processors.unstructured import (
from nextcloud_mcp_server.document_processors.unstructured import ( # noqa: PLC0415
UnstructuredProcessor,
)
@@ -126,7 +175,7 @@ def initialize_document_processors():
if "tesseract" in config["processors"]:
tess_config = config["processors"]["tesseract"]
try:
from nextcloud_mcp_server.document_processors.tesseract import (
from nextcloud_mcp_server.document_processors.tesseract import ( # noqa: PLC0415
TesseractProcessor,
)
@@ -144,7 +193,7 @@ def initialize_document_processors():
if "pymupdf" in config["processors"]:
pymupdf_config = config["processors"]["pymupdf"]
try:
from nextcloud_mcp_server.document_processors.pymupdf import (
from nextcloud_mcp_server.document_processors.pymupdf import ( # noqa: PLC0415
PyMuPDFProcessor,
)
@@ -164,7 +213,7 @@ def initialize_document_processors():
if "custom" in config["processors"]:
custom_config = config["processors"]["custom"]
try:
from nextcloud_mcp_server.document_processors.custom_http import (
from nextcloud_mcp_server.document_processors.custom_http import ( # noqa: PLC0415
CustomHTTPProcessor,
)
@@ -430,8 +479,6 @@ class SmitheryConfigMiddleware:
) -> None:
if scope["type"] == "http":
# Extract config from query parameters
from urllib.parse import parse_qs
query_string = scope.get("query_string", b"").decode("utf-8")
params = parse_qs(query_string)
@@ -506,8 +553,6 @@ async def load_oauth_client_credentials(
# Try loading from SQLite storage
try:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
@@ -558,9 +603,6 @@ async def load_oauth_client_credentials(
logger.info(f"Requesting token type: {token_type}")
# Ensure OAuth client in SQLite storage
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
@@ -624,8 +666,6 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
)
# Initialize persistent storage (for webhook tracking and future features)
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
logger.info("Persistent storage initialized (webhook tracking enabled)")
@@ -690,7 +730,7 @@ async def setup_oauth_config():
logger.info(f"Performing OIDC discovery: {discovery_url}")
# Perform OIDC discovery
async with httpx.AsyncClient(follow_redirects=True) as client:
async with nextcloud_httpx_client(follow_redirects=True) as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -755,10 +795,6 @@ async def setup_oauth_config():
refresh_token_storage = None
if enable_offline_access:
try:
from nextcloud_mcp_server.auth.storage import (
RefreshTokenStorage,
)
# Validate encryption key before initializing
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
if not encryption_key:
@@ -880,8 +916,6 @@ async def setup_oauth_config():
oauth_client = None
if enable_offline_access and refresh_token_storage and is_external_idp:
# For external IdP mode, create generic OIDC client for token operations
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
# Note: This redirect_uri is for OAuth client initialization, not used for actual redirects
# since this client is used for backend token operations (exchange, refresh)
@@ -994,7 +1028,7 @@ async def setup_oauth_config_for_multi_user_basic(
# Perform OIDC discovery
try:
async with httpx.AsyncClient(
async with nextcloud_httpx_client(
timeout=30.0, follow_redirects=True
) as http_client:
response = await http_client.get(discovery_url)
@@ -1076,8 +1110,6 @@ async def setup_oauth_config_for_multi_user_basic(
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(
@@ -1543,8 +1575,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
else:
# BasicAuth mode - initialize storage for webhook management
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
basic_auth_storage = RefreshTokenStorage.from_env()
await basic_auth_storage.initialize()
logger.info("Initialized refresh token storage for webhook management")
@@ -1652,7 +1682,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Initialize Qdrant collection before starting background tasks
logger.info("Initializing Qdrant collection...")
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
try:
await get_qdrant_client() # Triggers collection creation if needed
@@ -1744,12 +1773,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode"
logger.info(f"Starting background vector sync tasks for {mode_desc}")
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.vector.oauth_sync import (
oauth_processor_task,
user_manager_task,
)
# Get nextcloud_host (from settings - already validated)
nextcloud_host_for_sync = settings.nextcloud_host
if not nextcloud_host_for_sync:
@@ -1814,7 +1837,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Initialize Qdrant collection before starting background tasks
logger.info("Initializing Qdrant collection...")
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
try:
await get_qdrant_client() # Triggers collection creation if needed
@@ -1825,6 +1847,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
f"Cannot start vector sync - Qdrant initialization failed: {e}"
) from e
# Clean up stale app passwords at startup (BasicAuth mode only)
if not oauth_enabled:
try:
removed = await token_storage.cleanup_invalid_app_passwords(
nextcloud_host=nextcloud_host_for_sync
)
if removed:
logger.info(
f"Cleaned up {len(removed)} stale app password(s): {removed}"
)
except Exception as e:
logger.warning(f"App password cleanup failed (non-fatal): {e}")
# Initialize shared state
send_stream, receive_stream = anyio.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size
@@ -1975,7 +2010,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Try to connect to Nextcloud
start_time = time.time()
try:
async with httpx.AsyncClient(timeout=2.0) as client:
async with nextcloud_httpx_client(timeout=2.0) as client:
response = await client.get(f"{nextcloud_host}/status.php")
duration = time.time() - start_time
if response.status_code == 200:
@@ -2012,7 +2047,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")
@@ -2029,9 +2064,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:
@@ -2112,20 +2147,6 @@ 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 (
create_webhook,
delete_webhook,
get_chunk_context,
get_installed_apps,
get_server_status,
get_user_session,
get_vector_sync_status,
list_webhooks,
revoke_user_access,
unified_search,
vector_search,
)
routes.append(Route("/api/v1/status", get_server_status, methods=["GET"]))
routes.append(
Route(
@@ -2148,12 +2169,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"]))
@@ -2166,8 +2211,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
@@ -2208,8 +2254,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
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
def oauth_protected_resource_metadata(request):
"""RFC 9728 Protected Resource Metadata endpoint.
@@ -2271,12 +2315,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
# Add unified OAuth callback endpoint supporting both flows
from nextcloud_mcp_server.auth.oauth_routes import (
oauth_authorize_nextcloud,
oauth_callback,
oauth_callback_nextcloud,
)
routes.append(Route("/oauth/callback", oauth_callback, methods=["GET"]))
logger.info(
"OAuth unified callback enabled: /oauth/callback?flow={browser|provisioning}"
@@ -2305,8 +2343,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# 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)")
@@ -2314,12 +2350,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# 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,
oauth_logout,
)
routes.append(
Route("/oauth/login", oauth_login, methods=["GET"], name="oauth_login")
)
@@ -2342,24 +2372,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Add user info routes (available in both BasicAuth and OAuth modes)
# ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
# These require session authentication, so we wrap them in a separate app
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
from nextcloud_mcp_server.auth.userinfo_routes import (
revoke_session,
user_info_html,
vector_sync_status_fragment,
)
from nextcloud_mcp_server.auth.viz_routes import (
chunk_context_endpoint,
vector_visualization_html,
vector_visualization_search,
)
from nextcloud_mcp_server.auth.webhook_routes import (
disable_webhook_preset,
enable_webhook_preset,
webhook_management_pane,
)
# Create a separate Starlette app for browser routes that need session auth
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
browser_routes = [
@@ -9,7 +9,7 @@ import logging
import time
from typing import Optional
import httpx
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -60,7 +60,7 @@ class AstrolabeClient:
# Discover token endpoint
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
async with httpx.AsyncClient() as client:
async with nextcloud_httpx_client() as client:
logger.debug(f"Discovering token endpoint from {discovery_url}")
discovery_resp = await client.get(discovery_url)
discovery_resp.raise_for_status()
@@ -107,7 +107,7 @@ class AstrolabeClient:
token = await self.get_access_token()
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
async with httpx.AsyncClient() as client:
async with nextcloud_httpx_client() as client:
logger.debug(f"Retrieving app password for user: {user_id}")
response = await client.get(
@@ -11,6 +11,7 @@ import secrets
import time
from base64 import urlsafe_b64encode
from urllib.parse import urlencode
from urllib.parse import urlparse as parse_url
import httpx
import jwt
@@ -22,6 +23,8 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
_query_idp_userinfo,
)
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -142,7 +145,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
)
# Fetch authorization endpoint
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -151,8 +154,6 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
# Replace internal Docker hostname with public URL
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
from urllib.parse import urlparse as parse_url
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
@@ -286,7 +287,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if code_verifier:
token_params["code_verifier"] = code_verifier
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(
oauth_client.token_endpoint,
data=token_params,
@@ -296,7 +297,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
else:
# Integrated mode (Nextcloud OIDC)
discovery_url = oauth_config.get("discovery_url")
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -314,7 +315,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if code_verifier:
token_params["code_verifier"] = code_verifier
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(
token_endpoint,
data=token_params,
@@ -10,6 +10,8 @@ import httpx
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -132,7 +134,7 @@ async def register_client(
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
logger.debug(f"Registration endpoint: {registration_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as client:
async with nextcloud_httpx_client(timeout=30.0) as client:
try:
response = await client.post(
registration_endpoint,
@@ -229,7 +231,7 @@ async def delete_client(
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as http_client:
async with nextcloud_httpx_client(timeout=30.0) as http_client:
for attempt in range(max_retries):
try:
# Prefer RFC 7592 Bearer token authentication
+1 -2
View File
@@ -10,6 +10,7 @@ import logging
import os
from dataclasses import dataclass
from typing import Dict, List, Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
@@ -161,8 +162,6 @@ class ClientRegistry:
True if valid, False otherwise
"""
# Parse the redirect URI
from urllib.parse import urlparse
parsed = urlparse(redirect_uri)
# Check against registered patterns
+3 -1
View File
@@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse
import httpx
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -107,7 +109,7 @@ class KeycloakOAuthClient:
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client"""
if self._http_client is None:
self._http_client = httpx.AsyncClient(timeout=30.0)
self._http_client = nextcloud_httpx_client(timeout=30.0)
return self._http_client
async def close(self) -> None:
+9 -16
View File
@@ -26,15 +26,18 @@ import secrets
import time
from base64 import urlsafe_b64encode
from urllib.parse import urlencode
from urllib.parse import urlparse as parse_url
import httpx
import jwt
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
from nextcloud_mcp_server.auth.client_registry import get_client_registry
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -218,7 +221,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
)
# Fetch authorization endpoint from discovery
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -227,8 +230,6 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
from urllib.parse import urlparse as parse_url
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
# Parse internal and authorization endpoint to compare hostnames
@@ -354,7 +355,7 @@ async def oauth_authorize_nextcloud(
status_code=500,
)
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -363,8 +364,6 @@ async def oauth_authorize_nextcloud(
# Fix internal hostname for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
from urllib.parse import urlparse as parse_url
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
@@ -462,7 +461,7 @@ async def oauth_callback_nextcloud(request: Request):
callback_uri = f"{mcp_server_url}/oauth/callback"
discovery_url = oauth_config.get("discovery_url")
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -482,7 +481,7 @@ async def oauth_callback_nextcloud(request: Request):
token_params["code_verifier"] = code_verifier
# Exchange code for tokens
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(
token_endpoint,
data=token_params,
@@ -566,8 +565,6 @@ async def oauth_callback_nextcloud(request: Request):
</html>
"""
from starlette.responses import HTMLResponse
return HTMLResponse(content=success_html, status_code=200)
@@ -632,10 +629,6 @@ async def oauth_callback(request: Request):
elif flow_type == "browser":
# Browser UI Login - establish browser session for /user/page access
logger.info("Routing to browser login flow")
from nextcloud_mcp_server.auth.browser_oauth_routes import (
oauth_login_callback,
)
return await oauth_login_callback(request)
else:
@@ -15,6 +15,7 @@ from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
@@ -66,8 +67,6 @@ def require_provisioning(func: Callable) -> Callable:
# Check if we're in token exchange mode - if so, skip provisioning check
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
# Token exchange mode - per-request exchange, no provisioning needed
@@ -9,6 +9,8 @@ from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
@@ -132,8 +134,6 @@ def require_scopes(*required_scopes: str):
# Check if offline access is enabled
# 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
+241 -4
View File
@@ -34,8 +34,12 @@ from pathlib import Path
from typing import Any, Optional
import aiosqlite
import anyio
import httpx
from anyio import to_thread
from cryptography.fernet import Fernet
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
from nextcloud_mcp_server.observability.metrics import record_db_operation
logger = logging.getLogger(__name__)
@@ -164,10 +168,6 @@ class RefreshTokenStorage:
# Run migrations in a worker thread using anyio.to_thread
# This allows Alembic to run its own async operations in a separate context
from anyio import to_thread
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
if not has_alembic:
if has_schema:
# Stamp existing database without running migrations
@@ -1240,6 +1240,243 @@ 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 cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]:
"""
Validate stored app passwords against Nextcloud and remove invalid ones.
Makes a lightweight OCS request for each stored user to check if credentials
are still valid. Removes entries that return 401/403.
Args:
nextcloud_host: Nextcloud base URL
Returns:
List of user IDs whose app passwords were removed
"""
if not self._initialized:
await self.initialize()
user_ids = await self.get_all_app_password_user_ids()
if not user_ids:
return []
removed: list[str] = []
async def _validate_user(user_id: str) -> None:
app_password = await self.get_app_password(user_id)
if not app_password:
return
try:
async with httpx.AsyncClient(
base_url=nextcloud_host,
auth=httpx.BasicAuth(user_id, app_password),
timeout=10.0,
) as client:
response = await client.get(
"/ocs/v2.php/cloud/user",
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
)
if response.status_code in (401, 403):
logger.info(
f"App password for {user_id} is invalid "
f"(HTTP {response.status_code}), removing"
)
await self.delete_app_password(user_id)
removed.append(user_id)
else:
logger.debug(
f"App password for {user_id} validated "
f"(HTTP {response.status_code})"
)
except Exception as e:
logger.warning(f"Could not validate app password for {user_id}: {e}")
async with anyio.create_task_group() as tg:
for user_id in user_ids:
tg.start_soon(_validate_user, user_id)
return removed
async def generate_encryption_key() -> str:
"""
+3 -1
View File
@@ -25,6 +25,8 @@ import jwt
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -136,7 +138,7 @@ class TokenBrokerService:
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
if self._http_client is None:
self._http_client = httpx.AsyncClient(
self._http_client = nextcloud_httpx_client(
timeout=httpx.Timeout(30.0), follow_redirects=True
)
return self._http_client
+2 -1
View File
@@ -20,6 +20,7 @@ import httpx
import jwt
from ..config import get_settings
from ..http import nextcloud_httpx_client
from .storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
@@ -68,7 +69,7 @@ class TokenExchangeService:
self.storage: Optional[RefreshTokenStorage] = None
# Create HTTP client
self.http_client = httpx.AsyncClient(
self.http_client = nextcloud_httpx_client(
timeout=30.0,
follow_redirects=True,
)
@@ -31,6 +31,8 @@ from nextcloud_mcp_server.observability.metrics import (
record_oauth_token_validation,
)
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -61,7 +63,7 @@ class UnifiedTokenVerifier(TokenVerifier):
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
# Common components for all modes
self.http_client = httpx.AsyncClient(timeout=10.0)
self.http_client = nextcloud_httpx_client(timeout=10.0)
# JWT verification support
self.jwks_client: PyJWKClient | None = None
+16 -16
View File
@@ -13,13 +13,17 @@ import traceback
from pathlib import Path
from typing import Any
import httpx
from httpx import BasicAuth
from jinja2 import Environment, FileSystemLoader
from starlette.authentication import requires
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -54,8 +58,6 @@ async def _get_authenticated_client_for_userinfo(request: Request) -> NextcloudC
if not all([nextcloud_host, username, password]):
raise RuntimeError("BasicAuth credentials not configured")
from httpx import BasicAuth
assert nextcloud_host is not None
assert username is not None
assert password is not None
@@ -106,9 +108,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:
@@ -127,10 +129,10 @@ 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
from nextcloud_mcp_server.vector.qdrant_client import ( # noqa: PLC0415
get_qdrant_client,
)
settings = get_settings()
qdrant_client = await get_qdrant_client()
# Count documents in collection
@@ -258,7 +260,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
async with nextcloud_httpx_client(timeout=10.0) as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -291,7 +293,7 @@ async def _query_idp_userinfo(
User info dictionary from IdP, or None if query fails
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
async with nextcloud_httpx_client(timeout=10.0) as client:
response = await client.get(
userinfo_uri,
headers={"Authorization": f"Bearer {access_token_str}"},
@@ -431,8 +433,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
# Check if user is admin (for Webhooks tab)
is_admin = False
try:
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
# Get authenticated Nextcloud client
nc_client = await _get_authenticated_client_for_userinfo(request)
is_admin = await is_nextcloud_admin(request, nc_client._client)
@@ -471,8 +471,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
# Get Nextcloud host for generating links to apps (used by viz tab)
# Use public issuer URL if available (for browser-accessible links),
# otherwise fall back to NEXTCLOUD_HOST from settings
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
nextcloud_host_for_links = (
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
@@ -634,7 +632,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")
+6 -13
View File
@@ -18,16 +18,22 @@ from pathlib import Path
import anyio
import numpy as np
from jinja2 import Environment, FileSystemLoader
from qdrant_client.models import FieldCondition, Filter, MatchValue
from starlette.authentication import requires
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from nextcloud_mcp_server.auth.userinfo_routes import (
_get_authenticated_client_for_userinfo,
)
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding.service import get_embedding_service
from nextcloud_mcp_server.observability.tracing import trace_operation
from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
)
from nextcloud_mcp_server.search.context import get_chunk_with_context
from nextcloud_mcp_server.vector.pca import PCA
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
@@ -137,10 +143,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
# Get authenticated HTTP client from session
# In BasicAuth mode: uses username/password from session
# In OAuth mode: uses access token from session
from nextcloud_mcp_server.auth.userinfo_routes import (
_get_authenticated_client_for_userinfo,
)
with trace_operation("vector_viz.get_auth_client"):
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
@@ -353,8 +355,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
)
else:
# Fallback: generate embedding if not available from search
from nextcloud_mcp_server.embedding.service import get_embedding_service
embedding_service = get_embedding_service()
query_embedding = await embedding_service.embed(query)
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
@@ -555,11 +555,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
doc_id_int = int(doc_id)
# Get authenticated Nextcloud client
from nextcloud_mcp_server.auth.userinfo_routes import (
_get_authenticated_client_for_userinfo,
)
from nextcloud_mcp_server.search.context import get_chunk_with_context
# Use context expansion module to fetch chunk with surrounding context
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
chunk_context = await get_chunk_with_context(
@@ -594,8 +589,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
page_number = None
if doc_type == "file":
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
settings = get_settings()
qdrant_client = await get_qdrant_client()
username = request.user.display_name
+4 -2
View File
@@ -20,6 +20,8 @@ from nextcloud_mcp_server.server.webhook_presets import (
get_preset,
)
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -140,7 +142,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
assert nextcloud_host is not None # Type narrowing for type checker
assert username is not None and password is not None # Type narrowing
return httpx.AsyncClient(
return nextcloud_httpx_client(
base_url=nextcloud_host,
auth=(username, password),
timeout=30.0,
@@ -163,7 +165,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
if not nextcloud_host:
raise RuntimeError("Nextcloud host not configured")
return httpx.AsyncClient(
return nextcloud_httpx_client(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0,
+7 -10
View File
@@ -6,6 +6,13 @@ import uvicorn
from nextcloud_mcp_server.config import (
get_settings,
)
from nextcloud_mcp_server.migrations import (
create_migration,
downgrade_database,
get_current_revision,
show_migration_history,
upgrade_database,
)
from nextcloud_mcp_server.observability import get_uvicorn_logging_config
from .app import get_app
@@ -289,8 +296,6 @@ def upgrade(database_path: str, revision: str):
# Use custom database path
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
"""
from nextcloud_mcp_server.migrations import upgrade_database
try:
click.echo(f"Upgrading database to revision: {revision}")
upgrade_database(database_path, revision)
@@ -335,8 +340,6 @@ def downgrade(database_path: str, revision: str):
# Downgrade to base (empty database)
$ nextcloud-mcp-server db downgrade --revision base
"""
from nextcloud_mcp_server.migrations import downgrade_database
try:
click.echo(f"Downgrading database to revision: {revision}")
downgrade_database(database_path, revision)
@@ -362,8 +365,6 @@ def current(database_path: str):
Example:
$ nextcloud-mcp-server db current
"""
from nextcloud_mcp_server.migrations import get_current_revision
try:
revision = get_current_revision(database_path)
if revision:
@@ -397,8 +398,6 @@ def history(database_path: str):
Example:
$ nextcloud-mcp-server db history
"""
from nextcloud_mcp_server.migrations import show_migration_history
try:
click.echo("Migration history:")
show_migration_history(database_path)
@@ -421,8 +420,6 @@ def migrate(message: str):
Note: You must manually edit the generated migration file to add SQL statements.
"""
from nextcloud_mcp_server.migrations import create_migration
try:
click.echo(f"Creating new migration: {message}")
create_migration(message)
+3 -3
View File
@@ -4,7 +4,6 @@ import os
from httpx import (
AsyncBaseTransport,
AsyncClient,
AsyncHTTPTransport,
Auth,
BasicAuth,
Request,
@@ -13,6 +12,7 @@ from httpx import (
)
from ..controllers.notes_search import NotesSearchController
from ..http import nextcloud_httpx_transport
from .calendar import CalendarClient
from .contacts import ContactsClient
from .cookbook import CookbookClient
@@ -67,7 +67,7 @@ class NextcloudClient:
self._client = AsyncClient(
base_url=base_url,
auth=auth,
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()),
event_hooks={"request": [log_request], "response": [log_response]},
timeout=Timeout(timeout=30, connect=5),
)
@@ -113,7 +113,7 @@ class NextcloudClient:
Returns:
NextcloudClient configured with bearer token authentication
"""
from ..auth import BearerAuth
from ..auth import BearerAuth # noqa: PLC0415
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
+202 -83
View File
@@ -6,12 +6,16 @@ import uuid
from typing import Any, Dict, List, Optional
import anyio
from caldav.async_collection import AsyncCalendar
from caldav.async_collection import AsyncCalendar, AsyncEvent
from caldav.async_davclient import AsyncDAVClient
from caldav.elements import cdav, dav
from httpx import Auth
from icalendar import Alarm, Calendar, vRecur
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
from icalendar import Event as ICalEvent
from icalendar import Todo as ICalTodo
from lxml import etree # type: ignore[import-untyped]
from ..config import get_nextcloud_ssl_verify
logger = logging.getLogger(__name__)
@@ -34,6 +38,7 @@ class CalendarClient:
url=f"{base_url}/remote.php/dav/",
username=username,
auth=auth,
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to httpx which accepts SSLContext
)
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
@@ -100,8 +105,6 @@ class CalendarClient:
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
# Apple iCal namespace which Nextcloud doesn't recognize.
from lxml import etree # type: ignore[import-untyped]
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
@@ -255,18 +258,35 @@ class CalendarClient:
"""List events in a calendar within date range."""
calendar = self._get_calendar(calendar_name)
# Get all events using caldav library (now with proper filter)
events = await calendar.events()
if start_datetime or end_datetime:
# Build CalDAV REPORT with time-range filter for server-side filtering
events = await self._search_events_by_date(
calendar, start_datetime, end_datetime
)
# Expand is only used when both bounds are provided
expanded = bool(start_datetime and end_datetime)
else:
# No date filter — fetch all events
events = await calendar.events()
expanded = False
result = []
for event in events:
await event.load(only_if_unloaded=True)
if event.data:
event_dict = self._parse_ical_event(event.data)
if event_dict:
event_dict["href"] = str(event.url)
event_dict["etag"] = ""
result.append(event_dict)
if expanded:
# Server-side expansion: each response resource may contain
# multiple VEVENTs (one per recurrence occurrence)
for event_dict in self._parse_all_ical_events(event.data):
event_dict["href"] = str(event.url)
event_dict["etag"] = ""
result.append(event_dict)
else:
event_dict = self._parse_ical_event(event.data)
if event_dict:
event_dict["href"] = str(event.url)
event_dict["etag"] = ""
result.append(event_dict)
if len(result) >= limit:
break
@@ -274,6 +294,53 @@ class CalendarClient:
logger.debug(f"Found {len(result)} events")
return result
async def _search_events_by_date(
self,
calendar: AsyncCalendar,
start_datetime: Optional[dt.datetime] = None,
end_datetime: Optional[dt.datetime] = None,
) -> list:
"""Execute a CalDAV REPORT with time-range filter."""
# Ensure naive datetimes are treated as UTC
if start_datetime and start_datetime.tzinfo is None:
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
if end_datetime and end_datetime.tzinfo is None:
end_datetime = end_datetime.replace(tzinfo=dt.UTC)
# Build comp-filter with time-range (mirrors sync Calendar.build_search_xml_query)
inner_comp_filter = cdav.CompFilter(name="VEVENT")
inner_comp_filter += cdav.TimeRange(start_datetime, end_datetime)
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
filter_element = cdav.Filter() + outer_comp_filter
# When both bounds are provided, request server-side expansion of
# recurring events (RFC 4791 §9.6.5). Each occurrence is returned as
# a separate VEVENT with its own DTSTART, with RRULE stripped.
data = cdav.CalendarData()
if start_datetime and end_datetime:
data += cdav.Expand(start_datetime, end_datetime)
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
body = etree.tostring(
query.xmlelement(), encoding="utf-8", xml_declaration=True
)
assert calendar.client is not None
response = await calendar.client.report(str(calendar.url), body, depth=1)
# Parse response (same pattern as AsyncCalendar.search)
objects = []
response_data = response.expand_simple_props([cdav.CalendarData()])
for href, props in response_data.items():
if href == str(calendar.url):
continue
cal_data = props.get(cdav.CalendarData.tag)
if cal_data:
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
objects.append(obj)
return objects
async def create_event(
self, calendar_name: str, event_data: Dict[str, Any]
) -> Dict[str, Any]:
@@ -583,7 +650,7 @@ class CalendarClient:
# Add categories
categories = event_data.get("categories", "")
if categories:
event.add("categories", categories.split(","))
event.add("categories", [c.strip() for c in categories.split(",")])
# Add priority and status
priority = event_data.get("priority", 5)
@@ -633,75 +700,92 @@ class CalendarClient:
cal.add_component(event)
return cal.to_ical().decode("utf-8")
def _extract_vevent_data(self, component) -> Dict[str, Any]:
"""Extract event data from a single VEVENT component.
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
"""
event_data: Dict[str, Any] = {
"uid": str(component.get("uid", "")),
"title": str(component.get("summary", "")),
"description": str(component.get("description", "")),
"location": str(component.get("location", "")),
"status": str(component.get("status", "CONFIRMED")),
"priority": int(component.get("priority", 5)),
"privacy": str(component.get("class", "PUBLIC")),
"url": str(component.get("url", "")),
}
# Handle dates
dtstart = component.get("dtstart")
if dtstart:
if isinstance(dtstart.dt, dt.date) and not isinstance(
dtstart.dt, dt.datetime
):
event_data["start_datetime"] = dtstart.dt.isoformat()
event_data["all_day"] = True
else:
event_data["start_datetime"] = dtstart.dt.isoformat()
event_data["all_day"] = False
dtend = component.get("dtend")
if dtend:
if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime):
event_data["end_datetime"] = dtend.dt.isoformat()
else:
event_data["end_datetime"] = dtend.dt.isoformat()
# Handle categories
categories = component.get("categories")
if categories:
event_data["categories"] = self._extract_categories(categories)
# Handle recurrence
rrule = component.get("rrule")
if rrule:
event_data["recurring"] = True
event_data["recurrence_rule"] = str(rrule)
# Handle attendees
attendees = []
for attendee in component.get("attendee", []):
if isinstance(attendee, list):
attendees.extend(str(a).replace("mailto:", "") for a in attendee)
else:
attendees.append(str(attendee).replace("mailto:", ""))
if attendees:
event_data["attendees"] = ",".join(attendees)
return event_data
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
"""Parse iCalendar text and extract event data."""
"""Parse iCalendar text and extract the first event."""
try:
cal = Calendar.from_ical(ical_text)
for component in cal.walk():
if component.name == "VEVENT":
event_data = {
"uid": str(component.get("uid", "")),
"title": str(component.get("summary", "")),
"description": str(component.get("description", "")),
"location": str(component.get("location", "")),
"status": str(component.get("status", "CONFIRMED")),
"priority": int(component.get("priority", 5)),
"privacy": str(component.get("class", "PUBLIC")),
"url": str(component.get("url", "")),
}
# Handle dates
dtstart = component.get("dtstart")
if dtstart:
if isinstance(dtstart.dt, dt.date) and not isinstance(
dtstart.dt, dt.datetime
):
event_data["start_datetime"] = dtstart.dt.isoformat()
event_data["all_day"] = True
else:
event_data["start_datetime"] = dtstart.dt.isoformat()
event_data["all_day"] = False
dtend = component.get("dtend")
if dtend:
if isinstance(dtend.dt, dt.date) and not isinstance(
dtend.dt, dt.datetime
):
event_data["end_datetime"] = dtend.dt.isoformat()
else:
event_data["end_datetime"] = dtend.dt.isoformat()
# Handle categories
categories = component.get("categories")
if categories:
event_data["categories"] = self._extract_categories(categories)
# Handle recurrence
rrule = component.get("rrule")
if rrule:
event_data["recurring"] = True
event_data["recurrence_rule"] = str(rrule)
# Handle attendees
attendees = []
for attendee in component.get("attendee", []):
if isinstance(attendee, list):
attendees.extend(
str(a).replace("mailto:", "") for a in attendee
)
else:
attendees.append(str(attendee).replace("mailto:", ""))
if attendees:
event_data["attendees"] = ",".join(attendees)
return event_data
return self._extract_vevent_data(component)
return None
except Exception as e:
logger.error(f"Error parsing iCalendar event: {e}")
return None
def _parse_all_ical_events(self, ical_text: str) -> list[Dict[str, Any]]:
"""Parse iCalendar text and extract ALL event occurrences.
Used with server-side expansion where a single VCALENDAR contains
multiple VEVENT components (one per recurrence occurrence).
"""
results: list[Dict[str, Any]] = []
try:
cal = Calendar.from_ical(ical_text)
for component in cal.walk():
if component.name == "VEVENT":
results.append(self._extract_vevent_data(component))
except Exception as e:
logger.error(f"Error parsing iCalendar events: {e}")
return results
def _merge_ical_properties(
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
) -> str:
@@ -727,6 +811,50 @@ class CalendarClient:
if "url" in event_data:
component["URL"] = event_data["url"]
# Handle categories
if "categories" in event_data:
categories_str = event_data["categories"]
if categories_str:
component["CATEGORIES"] = [
c.strip() for c in categories_str.split(",")
]
elif "CATEGORIES" in component:
del component["CATEGORIES"]
# Handle recurrence rule
if "recurrence_rule" in event_data:
rrule_str = event_data["recurrence_rule"]
if rrule_str:
component["RRULE"] = vRecur.from_ical(rrule_str)
elif "RRULE" in component:
del component["RRULE"]
# Handle attendees
if "attendees" in event_data:
attendees_str = event_data["attendees"]
# Remove all existing attendees first
while "ATTENDEE" in component:
del component["ATTENDEE"]
if attendees_str:
for email in attendees_str.split(","):
if email.strip():
component.add("attendee", f"mailto:{email.strip()}")
# Handle reminder (VALARM)
if "reminder_minutes" in event_data:
component.subcomponents = [
sub
for sub in component.subcomponents
if sub.name != "VALARM"
]
minutes = event_data["reminder_minutes"]
if minutes > 0:
alarm = Alarm()
alarm.add("action", "DISPLAY")
alarm.add("description", "Event reminder")
alarm.add("trigger", dt.timedelta(minutes=-minutes))
component.add_component(alarm)
# Handle dates
if "start_datetime" in event_data:
start_str = event_data["start_datetime"]
@@ -757,8 +885,6 @@ class CalendarClient:
component["DTEND"] = end_dt
# Update timestamps
from icalendar import vDDDTypes
now = dt.datetime.now(dt.UTC)
component["LAST-MODIFIED"] = vDDDTypes(now)
component["DTSTAMP"] = vDDDTypes(now)
@@ -823,24 +949,18 @@ class CalendarClient:
# Due date
due = todo_data.get("due", "")
if due:
from icalendar import vDDDTypes
due_dt = self._ensure_timezone_aware(due)
todo.add("due", vDDDTypes(due_dt))
# Start date
dtstart = todo_data.get("dtstart", "")
if dtstart:
from icalendar import vDDDTypes
start_dt = self._ensure_timezone_aware(dtstart)
todo.add("dtstart", vDDDTypes(start_dt))
# Completed timestamp
completed = todo_data.get("completed", "")
if completed:
from icalendar import vDDDTypes
completed_dt = self._ensure_timezone_aware(completed)
todo.add("completed", vDDDTypes(completed_dt))
@@ -929,9 +1049,6 @@ class CalendarClient:
component["PERCENT-COMPLETE"] = percent_value
logger.debug(f"Set PERCENT-COMPLETE to {percent_value}")
# Import vDDDTypes at the beginning for datetime formatting
from icalendar import vDDDTypes
# Handle due date
if "due" in todo_data:
due_str = todo_data["due"]
@@ -960,7 +1077,9 @@ class CalendarClient:
if "categories" in todo_data:
categories_str = todo_data["categories"]
if categories_str:
component["CATEGORIES"] = categories_str.split(",")
component["CATEGORIES"] = [
c.strip() for c in categories_str.split(",")
]
logger.debug(f"Set CATEGORIES to {categories_str}")
# Update timestamps
+1
View File
@@ -274,6 +274,7 @@ class ContactsClient(BaseNextcloudClient):
"nickname": contact.nickname,
"birthday": contact.bday,
"email": contact.email,
"tel": contact.tel,
},
"addressdata": addressdata,
}
+1 -9
View File
@@ -2,6 +2,7 @@
import logging
from typing import Any, Dict, List
from urllib.parse import quote
from httpx import Timeout
@@ -164,9 +165,6 @@ class CookbookClient(BaseNextcloudClient):
Returns:
List of matching recipe stubs
"""
# URL encode the query
from urllib.parse import quote
encoded_query = quote(query)
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
@@ -193,8 +191,6 @@ class CookbookClient(BaseNextcloudClient):
Returns:
List of recipe stubs in the category
"""
from urllib.parse import quote
encoded_category = quote(category)
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
@@ -211,8 +207,6 @@ class CookbookClient(BaseNextcloudClient):
Returns:
New category name
"""
from urllib.parse import quote
encoded_old_name = quote(old_name)
response = await self._make_request(
"PUT",
@@ -241,8 +235,6 @@ class CookbookClient(BaseNextcloudClient):
Returns:
List of recipe stubs matching the keywords
"""
from urllib.parse import quote
# Join keywords with commas
keywords_str = ",".join(keywords)
encoded_keywords = quote(keywords_str)
+7 -1
View File
@@ -386,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
+1 -5
View File
@@ -4,6 +4,7 @@ import logging
from typing import Any, AsyncIterator, Dict, Optional
from .base import BaseNextcloudClient
from .webdav import WebDAVClient
logger = logging.getLogger(__name__)
@@ -157,9 +158,6 @@ class NotesClient(BaseNextcloudClient):
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
)
try:
# Import here to avoid circular imports
from .webdav import WebDAVClient
webdav_client = WebDAVClient(self._client, self.username)
await webdav_client.cleanup_old_attachment_directory(
note_id=note_id, old_category=old_note.get("category", "")
@@ -204,8 +202,6 @@ class NotesClient(BaseNextcloudClient):
# Clean up attachment directories
try:
from .webdav import WebDAVClient
webdav_client = WebDAVClient(self._client, self.username)
for cat in potential_categories:
+2 -4
View File
@@ -3,7 +3,9 @@
import logging
import mimetypes
import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import unquote
from httpx import HTTPStatusError
@@ -1259,8 +1261,6 @@ class WebDAVClient(BaseNextcloudClient):
continue
# Decode href path and extract the file path
from urllib.parse import unquote
href_path = unquote(href_elem.text)
# Remove WebDAV prefix to get user-relative path
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
@@ -1269,8 +1269,6 @@ class WebDAVClient(BaseNextcloudClient):
# Parse last modified timestamp
last_modified_timestamp = None
if lastmodified_elem is not None and lastmodified_elem.text:
from email.utils import parsedate_to_datetime
try:
dt = parsedate_to_datetime(lastmodified_elem.text)
last_modified_timestamp = int(dt.timestamp())
+42 -1
View File
@@ -2,6 +2,7 @@ import logging
import logging.config
import os
import socket
import ssl
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional
@@ -181,6 +182,10 @@ class Settings:
nextcloud_username: Optional[str] = None
nextcloud_password: Optional[str] = None
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl: bool = True
nextcloud_ca_bundle: Optional[str] = None
# ADR-005: Token Audience Validation (required for OAuth mode)
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
@@ -252,9 +257,23 @@ class Settings:
log_include_trace_context: bool = True
def __post_init__(self):
"""Validate Qdrant configuration and set defaults."""
"""Validate configuration and set defaults."""
logger = logging.getLogger(__name__)
# Validate SSL/TLS configuration
if not self.nextcloud_verify_ssl:
logger.warning(
"NEXTCLOUD_VERIFY_SSL is disabled. "
"TLS certificate verification is turned off for all Nextcloud connections. "
"This is insecure and should only be used for development/testing."
)
if self.nextcloud_ca_bundle:
if not os.path.isfile(self.nextcloud_ca_bundle):
raise ValueError(
f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}"
)
logger.info("Using custom CA bundle: %s", self.nextcloud_ca_bundle)
# Ensure mutual exclusivity
if self.qdrant_url and self.qdrant_location:
raise ValueError(
@@ -504,6 +523,11 @@ def get_settings() -> Settings:
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl=(
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
),
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
# ADR-005: Token Audience Validation
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
@@ -569,3 +593,20 @@ def get_settings() -> Settings:
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
== "true",
)
def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext:
"""Return the SSL verification setting for Nextcloud connections.
Returns:
- False if NEXTCLOUD_VERIFY_SSL=false (disable verification)
- ssl.SSLContext if NEXTCLOUD_CA_BUNDLE is set (custom CA)
- True otherwise (default system CA verification)
"""
settings = get_settings()
if not settings.nextcloud_verify_ssl:
return False
if settings.nextcloud_ca_bundle:
ctx = ssl.create_default_context(cafile=settings.nextcloud_ca_bundle)
return ctx
return True
+5 -6
View File
@@ -5,6 +5,10 @@ import logging
from httpx import BasicAuth
from mcp.server.fastmcp import Context
from nextcloud_mcp_server.auth.context_helper import (
get_client_from_context,
get_session_client_from_context,
)
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import (
DeploymentMode,
@@ -80,11 +84,6 @@ async def get_client(ctx: Context) -> NextcloudClient:
# OAuth mode (has 'nextcloud_host' attribute)
if hasattr(lifespan_ctx, "nextcloud_host"):
from nextcloud_mcp_server.auth.context_helper import (
get_client_from_context,
get_session_client_from_context,
)
if settings.enable_token_exchange:
# Mode 2: Exchange MCP token for Nextcloud token
# Token was validated to have MCP audience in UnifiedTokenVerifier
@@ -131,7 +130,7 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
ValueError: If required session config fields are missing
"""
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
from nextcloud_mcp_server.app import get_smithery_session_config
from nextcloud_mcp_server.app import get_smithery_session_config # noqa: PLC0415
session_config = get_smithery_session_config()
+45
View File
@@ -0,0 +1,45 @@
"""Centralized HTTP client factory for Nextcloud connections.
All outbound connections to Nextcloud (API calls, OIDC endpoints) should use
these factories to ensure consistent SSL/TLS configuration from environment
variables (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE).
"""
from typing import Any
import httpx
from .config import get_nextcloud_ssl_verify
def nextcloud_httpx_client(**kwargs: Any) -> httpx.AsyncClient:
"""Create an httpx.AsyncClient with Nextcloud SSL settings applied.
Reads NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE from the environment
via ``get_nextcloud_ssl_verify()``. Caller-supplied ``verify`` kwarg
takes precedence if explicitly provided.
Args:
**kwargs: Forwarded to ``httpx.AsyncClient()``.
Returns:
Configured ``httpx.AsyncClient``.
"""
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
return httpx.AsyncClient(**kwargs)
def nextcloud_httpx_transport(**kwargs: Any) -> httpx.AsyncHTTPTransport:
"""Create an httpx.AsyncHTTPTransport with Nextcloud SSL settings applied.
Used by ``NextcloudClient`` which wraps the transport in
``AsyncDisableCookieTransport``.
Args:
**kwargs: Forwarded to ``httpx.AsyncHTTPTransport()``.
Returns:
Configured ``httpx.AsyncHTTPTransport``.
"""
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
return httpx.AsyncHTTPTransport(**kwargs)
+1 -2
View File
@@ -11,6 +11,7 @@ from pathlib import Path
from alembic.config import Config
import nextcloud_mcp_server.alembic as alembic_package
from alembic import command
logger = logging.getLogger(__name__)
@@ -30,8 +31,6 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
Returns:
Alembic Config object configured for the specified database
"""
from nextcloud_mcp_server import alembic as alembic_package
# Use package location (works in both editable and installed modes)
if alembic_package.__file__ is None:
raise RuntimeError("alembic package __file__ is None")
+6
View File
@@ -34,6 +34,12 @@ class CalendarEventSummary(BaseModel):
status: Optional[str] = Field(
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
)
calendar_name: Optional[str] = Field(
None, description="Calendar containing this event"
)
calendar_display_name: Optional[str] = Field(
None, description="Display name of calendar containing this event"
)
class CalendarEvent(CalendarEventSummary):
+14
View File
@@ -261,6 +261,20 @@ class CreateLabelResponse(BaseResponse):
color: str = Field(description="The created label color")
class ListCardsResponse(BaseResponse):
"""Response model for listing deck cards."""
cards: list[DeckCard] = Field(description="List of deck cards")
total: int = Field(description="Total number of cards")
class ListLabelsResponse(BaseResponse):
"""Response model for listing deck labels."""
labels: list[DeckLabel] = Field(description="List of deck labels")
total: int = Field(description="Total number of labels")
class LabelOperationResponse(StatusResponse):
"""Response model for label operations like update/delete."""
@@ -25,6 +25,8 @@ from prometheus_client import (
start_http_server,
)
from nextcloud_mcp_server.observability.tracing import trace_operation
logger = logging.getLogger(__name__)
# =============================================================================
@@ -426,8 +428,6 @@ def instrument_tool(func):
Wrapped function with metrics and tracing instrumentation
"""
from nextcloud_mcp_server.observability.tracing import trace_operation
@functools.wraps(func)
async def wrapper(*args, **kwargs):
tool_name = func.__name__
+4 -24
View File
@@ -9,8 +9,12 @@ from dataclasses import dataclass
import pymupdf
import pymupdf4llm
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
@@ -34,11 +38,6 @@ async def _get_chunk_from_qdrant(
Full chunk text from Qdrant excerpt field, or None if not found
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
@@ -104,11 +103,6 @@ async def _get_chunk_by_index_from_qdrant(
Full chunk text from Qdrant excerpt field, or None if not found
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
@@ -165,11 +159,6 @@ async def _get_file_path_from_qdrant(
File path string, or None if not found in Qdrant
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
@@ -225,11 +214,6 @@ async def _get_deck_metadata_from_qdrant(
Dictionary with board_id and stack_id, or None if not found
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
@@ -355,8 +339,6 @@ async def get_chunk_with_context(
# Fetch adjacent chunks for context expansion
# Get chunk overlap from config to remove duplicate text
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
chunk_overlap = settings.document_chunk_overlap
@@ -587,8 +569,6 @@ async def _fetch_document_text(
return None
elif doc_type == "news_item":
# Fetch news item by ID
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
item = await nc_client.news.get_item(int(doc_id))
# Reconstruct full content as indexed: title + source + URL + body
# This ensures chunk offsets align with indexed content structure
+3 -10
View File
@@ -12,11 +12,14 @@ import logging
import re
import shutil
import tempfile
from collections import defaultdict
from io import BytesIO
from pathlib import Path
from typing import Optional
import pymupdf
import pymupdf4llm
from PIL import Image, ImageDraw
logger = logging.getLogger(__name__)
@@ -682,8 +685,6 @@ class PDFHighlighter:
# Clean up temp directory and PDF file
if temp_pdf_path and temp_pdf_path.parent.exists():
try:
import shutil
shutil.rmtree(temp_pdf_path.parent)
except Exception as e:
logger.warning(
@@ -720,11 +721,6 @@ class PDFHighlighter:
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
Chunks that fail to highlight are omitted from the result.
"""
import shutil
import tempfile
from collections import defaultdict
from pathlib import Path
results: dict[int, tuple[bytes, int, int]] = {}
if not chunks:
@@ -798,9 +794,6 @@ class PDFHighlighter:
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
# This avoids expensive page.get_pixmap() calls per chunk
from io import BytesIO
from PIL import Image, ImageDraw
# PIL color for bounding box (RGB tuple)
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
+62 -8
View File
@@ -9,15 +9,46 @@ from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.calendar import (
Calendar,
CalendarEventSummary,
ListCalendarsResponse,
ListEventsResponse,
ListTodosResponse,
Todo,
UpcomingEventsResponse,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
"""Convert a raw event dict from the calendar client to a CalendarEventSummary."""
raw_categories = event.get("categories", [])
if isinstance(raw_categories, str):
categories = [c.strip() for c in raw_categories.split(",") if c.strip()]
else:
categories = raw_categories
start = event.get("start_datetime", "")
if not start:
logger.debug("Event %s has no start_datetime", event.get("uid", "unknown"))
return CalendarEventSummary(
uid=event.get("uid", ""),
summary=event.get("title", ""),
start=start,
end=event.get("end_datetime"),
all_day=event.get("all_day", False),
location=event.get("location") or None,
description=event.get("description") or None,
categories=categories,
status=event.get("status"),
calendar_name=event.get("calendar_name"),
calendar_display_name=event.get("calendar_display_name")
or event.get("calendar_name"),
)
def configure_calendar_tools(mcp: FastMCP):
# Calendar tools
@mcp.tool(
@@ -204,7 +235,7 @@ def configure_calendar_tools(mcp: FastMCP):
end_datetime=end_datetime,
filters=filters if filters else None,
)
return events[:limit]
events = events[:limit]
else:
# Search in specific calendar
events = await client.calendar.get_calendar_events(
@@ -214,11 +245,25 @@ def configure_calendar_tools(mcp: FastMCP):
limit=limit,
)
# Enrich events with calendar context for per-event mapping.
# Note: calendar_display_name is not available here without an
# extra list_calendars() call; the response-level calendar_name
# already identifies the calendar for single-calendar queries.
for event in events:
event["calendar_name"] = calendar_name
# Apply filters if provided
if filters:
events = client.calendar._apply_event_filters(events, filters)
return events
summaries = [_event_dict_to_summary(e) for e in events]
return ListEventsResponse(
events=summaries,
calendar_name=None if search_all_calendars else calendar_name,
start_date=start_date or None,
end_date=end_date or None,
total_found=len(summaries),
)
@mcp.tool(
title="Get Calendar Event",
@@ -420,12 +465,15 @@ def configure_calendar_tools(mcp: FastMCP):
if calendar_name:
# Get events from specific calendar
return await client.calendar.get_calendar_events(
events = await client.calendar.get_calendar_events(
calendar_name=calendar_name,
start_datetime=now,
end_datetime=end_datetime,
limit=limit,
)
# calendar_display_name not available without extra API call
for event in events:
event["calendar_name"] = calendar_name
else:
# Get events from all calendars
all_calendars = await client.calendar.list_calendars()
@@ -433,17 +481,16 @@ def configure_calendar_tools(mcp: FastMCP):
for calendar in all_calendars:
try:
events = await client.calendar.get_calendar_events(
cal_events = await client.calendar.get_calendar_events(
calendar_name=calendar["name"],
start_datetime=now,
end_datetime=end_datetime,
limit=limit,
)
# Add calendar info to each event
for event in events:
for event in cal_events:
event["calendar_name"] = calendar["name"]
event["calendar_display_name"] = calendar["display_name"]
all_events.extend(events)
all_events.extend(cal_events)
except Exception as e:
logger.warning(
f"Error getting events from calendar {calendar['name']}: {e}"
@@ -452,7 +499,14 @@ def configure_calendar_tools(mcp: FastMCP):
# Sort by start time and limit
all_events.sort(key=lambda x: x.get("start_datetime", ""))
return all_events[:limit]
events = all_events[:limit]
summaries = [_event_dict_to_summary(e) for e in events]
return UpcomingEventsResponse(
events=summaries,
days_ahead=days_ahead,
calendar_name=calendar_name or None,
)
@mcp.tool(
title="Find Availability",
+124 -8
View File
@@ -1,15 +1,95 @@
import logging
from typing import Any
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.contacts import (
AddressBook,
Contact,
ContactField,
ListAddressBooksResponse,
ListContactsResponse,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
def _parse_vcard_fields(
raw_values: str | dict | list | None, field_type: str
) -> list[ContactField]:
"""Parse polymorphic vCard field data into a list of ContactField.
pythonvCard4 returns field values in several shapes:
- ``str`` plain value, e.g. ``"alice@example.com"``
- ``dict`` ``{'value': '...', 'type': ['HOME', 'PREF']}``
- ``list`` a list whose items are any of the above
The ``PREF`` type parameter is treated as a *preferred* flag rather than a
label. All other type values are lowercased and joined with ``", "``.
"""
if raw_values is None:
return []
items: list[str | dict] = (
raw_values if isinstance(raw_values, list) else [raw_values]
)
fields: list[ContactField] = []
for item in items:
if isinstance(item, dict):
value = str(item.get("value", ""))
if not value:
continue
raw_types: list[str] = item.get("type") or []
preferred = any(t.upper() == "PREF" for t in raw_types)
labels = [t.lower() for t in raw_types if t.upper() != "PREF"]
fields.append(
ContactField(
type=field_type,
value=value,
label=", ".join(labels) if labels else None,
preferred=preferred,
)
)
elif isinstance(item, str) and item:
fields.append(ContactField(type=field_type, value=item))
return fields
def _raw_contact_to_model(raw: dict) -> Contact:
"""Convert a raw contact dict from the contacts client to a Contact model.
Maps fullname, nickname, birthday, email, and tel fields.
Email/tel values may be plain strings, dicts with ``value``/``type`` keys,
or lists of either see :func:`_parse_vcard_fields`.
"""
contact_info = raw.get("contact", {})
emails = _parse_vcard_fields(contact_info.get("email"), "email")
phones = _parse_vcard_fields(contact_info.get("tel"), "phone")
# Nickname goes into custom_fields (no dedicated model field)
custom_fields: dict[str, Any] = {}
nickname = contact_info.get("nickname")
if nickname:
custom_fields["nickname"] = nickname
return Contact(
uid=raw["vcard_id"],
fn=contact_info.get("fullname", ""),
etag=raw.get("getetag"),
birthday=contact_info.get("birthday"),
emails=emails,
phones=phones,
custom_fields=custom_fields,
)
def configure_contacts_tools(mcp: FastMCP):
# Contacts tools
@mcp.tool(
@@ -18,10 +98,23 @@ def configure_contacts_tools(mcp: FastMCP):
)
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_addressbooks(ctx: Context):
async def nc_contacts_list_addressbooks(ctx: Context) -> ListAddressBooksResponse:
"""List all addressbooks for the user."""
client = await get_client(ctx)
return await client.contacts.list_addressbooks()
addressbooks_data = await client.contacts.list_addressbooks()
addressbooks = [
AddressBook(
# ab["name"] is a short slug like "contacts", not a full CardDAV URI;
# all tools use it as a path segment: f"{carddav_path}/{name}/"
uri=ab["name"],
displayname=ab.get("display_name", ab["name"]),
ctag=ab.get("getctag"),
)
for ab in addressbooks_data
]
return ListAddressBooksResponse(
addressbooks=addressbooks, total_count=len(addressbooks)
)
@mcp.tool(
title="List Contacts",
@@ -29,10 +122,22 @@ def configure_contacts_tools(mcp: FastMCP):
)
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
"""List all contacts in the specified addressbook."""
async def nc_contacts_list_contacts(
ctx: Context, *, addressbook: str
) -> ListContactsResponse:
"""List all contacts in the specified addressbook.
Args:
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
"""
client = await get_client(ctx)
return await client.contacts.list_contacts(addressbook=addressbook)
contacts_data = await client.contacts.list_contacts(addressbook=addressbook)
contacts = [_raw_contact_to_model(c) for c in contacts_data]
return ListContactsResponse(
contacts=contacts, addressbook=addressbook, total_count=len(contacts)
)
@mcp.tool(
title="Create Address Book",
@@ -79,7 +184,9 @@ def configure_contacts_tools(mcp: FastMCP):
"""Create a new contact.
Args:
addressbook: The name of the addressbook to create the contact in.
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
uid: The unique ID for the contact.
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
"""
@@ -97,7 +204,14 @@ def configure_contacts_tools(mcp: FastMCP):
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
"""Delete a contact."""
"""Delete a contact.
Args:
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
uid: The unique ID of the contact to delete.
"""
client = await get_client(ctx)
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
@@ -113,7 +227,9 @@ def configure_contacts_tools(mcp: FastMCP):
"""Update an existing contact while preserving all existing properties.
Args:
addressbook: The name of the addressbook containing the contact.
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
uid: The unique ID of the contact to update.
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
etag: Optional ETag for optimistic concurrency control.
+21 -13
View File
@@ -17,6 +17,10 @@ from nextcloud_mcp_server.models.deck import (
DeckLabel,
DeckStack,
LabelOperationResponse,
ListBoardsResponse,
ListCardsResponse,
ListLabelsResponse,
ListStacksResponse,
StackOperationResponse,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
@@ -103,7 +107,7 @@ def configure_deck_tools(mcp: FastMCP):
)
client = await get_client(ctx)
board = await client.deck.get_board(board_id)
return [label.model_dump() for label in board.labels]
return [label.model_dump() for label in (board.labels or [])]
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
async def deck_label_resource(board_id: int, label_id: int):
@@ -124,11 +128,11 @@ def configure_deck_tools(mcp: FastMCP):
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
"""Get all Nextcloud Deck boards"""
client = await get_client(ctx)
boards = await client.deck.get_boards()
return boards
return ListBoardsResponse(boards=boards, total=len(boards))
@mcp.tool(
title="Get Deck Board",
@@ -148,11 +152,11 @@ def configure_deck_tools(mcp: FastMCP):
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
async def deck_get_stacks(ctx: Context, board_id: int) -> ListStacksResponse:
"""Get all stacks in a Nextcloud Deck board"""
client = await get_client(ctx)
stacks = await client.deck.get_stacks(board_id)
return stacks
return ListStacksResponse(stacks=stacks, total=len(stacks))
@mcp.tool(
title="Get Deck Stack",
@@ -174,13 +178,12 @@ def configure_deck_tools(mcp: FastMCP):
@instrument_tool
async def deck_get_cards(
ctx: Context, board_id: int, stack_id: int
) -> list[DeckCard]:
) -> ListCardsResponse:
"""Get all cards in a Nextcloud Deck stack"""
client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards:
return stack.cards
return []
cards = stack.cards or []
return ListCardsResponse(cards=cards, total=len(cards))
@mcp.tool(
title="Get Deck Card",
@@ -202,11 +205,12 @@ def configure_deck_tools(mcp: FastMCP):
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
async def deck_get_labels(ctx: Context, board_id: int) -> ListLabelsResponse:
"""Get all labels in a Nextcloud Deck board"""
client = await get_client(ctx)
board = await client.deck.get_board(board_id)
return board.labels
labels = board.labels or []
return ListLabelsResponse(labels=labels, total=len(labels))
@mcp.tool(
title="Get Deck Label",
@@ -637,7 +641,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 +698,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
+6 -11
View File
@@ -8,10 +8,10 @@ Nextcloud access using the Flow 2 (Resource Provisioning) OAuth flow.
import logging
import os
import secrets
from datetime import datetime, timezone
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
@@ -20,9 +20,13 @@ from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -69,7 +73,7 @@ async def extract_user_id_from_token(ctx: Context) -> str:
"OIDC_DISCOVERY_URI",
"http://localhost:8080/.well-known/openid-configuration",
)
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(oidc_discovery_uri)
discovery_response.raise_for_status()
discovery = discovery_response.json()
@@ -156,11 +160,6 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
Returns:
ProvisioningStatus with current provisioning state
"""
from datetime import datetime, timezone
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
# Check for app password first (interim solution)
@@ -304,8 +303,6 @@ async def provision_nextcloud_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(
@@ -489,8 +486,6 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
# Not logged in - generate OAuth URL for Flow 2
# 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 (
+9 -20
View File
@@ -1,22 +1,25 @@
"""Semantic search MCP tools using vector database."""
import logging
import os
import anyio
from httpx import RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import (
ClientCapabilities,
ErrorData,
ModelHint,
ModelPreferences,
SamplingCapability,
SamplingMessage,
TextContent,
ToolAnnotations,
)
from qdrant_client.models import Filter
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.semantic import (
SamplingSearchResponse,
@@ -29,6 +32,8 @@ from nextcloud_mcp_server.observability.metrics import (
)
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
from nextcloud_mcp_server.search.context import get_chunk_with_context
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__)
@@ -83,8 +88,6 @@ def configure_semantic_tools(mcp: FastMCP):
Returns:
SemanticSearchResponse with matching documents ranked by fusion scores
"""
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
client = await get_client(ctx)
username = client.username
@@ -374,8 +377,6 @@ def configure_semantic_tools(mcp: FastMCP):
)
# 3. Check if client supports sampling
from mcp.types import ClientCapabilities, SamplingCapability
client_has_sampling = ctx.session.check_client_capability(
ClientCapabilities(sampling=SamplingCapability())
)
@@ -658,12 +659,9 @@ def configure_semantic_tools(mcp: FastMCP):
after creating or updating content across all indexed apps.
"""
# 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 VectorSyncStatusResponse(
indexed_count=0,
pending_count=0,
@@ -696,15 +694,6 @@ def configure_semantic_tools(mcp: FastMCP):
# Get Qdrant client and query indexed count
indexed_count = 0
try:
from qdrant_client.models import Filter
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()
# Count documents in collection, excluding placeholders
+5 -2
View File
@@ -5,6 +5,7 @@ from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.tables import ListTablesResponse, Table
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
@@ -18,10 +19,12 @@ def configure_tables_tools(mcp: FastMCP):
)
@require_scopes("tables:read")
@instrument_tool
async def nc_tables_list_tables(ctx: Context):
async def nc_tables_list_tables(ctx: Context) -> ListTablesResponse:
"""List all tables available to the user"""
client = await get_client(ctx)
return await client.tables.list_tables()
tables_data = await client.tables.list_tables()
tables = [Table(**t) for t in tables_data]
return ListTablesResponse(tables=tables, total_count=len(tables))
@mcp.tool(
title="Get Table Schema",
+1 -1
View File
@@ -36,7 +36,7 @@ def main():
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
# Import app after setting environment variables
from nextcloud_mcp_server.app import get_app
from nextcloud_mcp_server.app import get_app # noqa: PLC0415
# Create the app with streamable-http transport (required for Smithery)
app = get_app(transport="streamable-http")
+100 -25
View File
@@ -8,8 +8,8 @@ Manages background vector sync for multi-user deployments:
Authentication strategies are mutually exclusive by deployment mode:
Multi-user BasicAuth mode (ENABLE_MULTI_USER_BASIC_AUTH=true):
- Uses app passwords obtained via Astrolabe Management API
- Users provision via Astrolabe personal settings
- 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):
@@ -31,15 +31,15 @@ from anyio.streams.memory import (
MemoryObjectReceiveStream,
MemoryObjectSendStream,
)
from httpx import BasicAuth
from httpx import BasicAuth, HTTPStatusError
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.processor import process_document
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
if TYPE_CHECKING:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
logger = logging.getLogger(__name__)
@@ -71,15 +71,18 @@ class UserSyncState:
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. OAuth is NOT used in this mode.
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
@@ -87,21 +90,13 @@ async def get_user_client_basic_auth(
Raises:
NotProvisionedError: If user has not provisioned an app password
"""
settings = get_settings()
# Get or create storage instance
if storage is None:
storage = RefreshTokenStorage.from_env()
await storage.initialize()
if not settings.oidc_client_id or not settings.oidc_client_secret:
raise NotProvisionedError(
"Astrolabe client credentials not configured. "
"Set OIDC_CLIENT_ID and OIDC_CLIENT_SECRET for app password retrieval."
)
astrolabe = AstrolabeClient(
nextcloud_host=nextcloud_host,
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
)
app_password = await astrolabe.get_user_app_password(user_id)
# Retrieve app password from local storage
app_password = await storage.get_app_password(user_id)
if not app_password:
raise NotProvisionedError(
@@ -214,9 +209,41 @@ async def user_scanner_task(
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
logger.info(f"[{mode_label}] Scanner started for user: {user_id}")
settings = get_settings()
max_consecutive_errors = 5
task_status.started()
# Pre-validate credentials before entering scan loop
try:
nc_client = await get_user_client(
user_id, token_broker, nextcloud_host, use_basic_auth=use_basic_auth
)
try:
await nc_client.capabilities() # Lightweight OCS call to validate creds
logger.info(f"[{mode_label}] Credentials validated for {user_id}")
except HTTPStatusError as e:
if e.response.status_code in (401, 403):
logger.warning(
f"[{mode_label}] Credential validation failed for {user_id} "
f"(HTTP {e.response.status_code}), not starting scan loop"
)
return
raise
finally:
await nc_client.close()
except NotProvisionedError:
logger.warning(
f"[{mode_label}] User {user_id} not provisioned, not starting scan loop"
)
return
except Exception as e:
logger.warning(
f"[{mode_label}] Pre-validation failed for {user_id}: {e}. "
f"Proceeding to scan loop (has its own error handling)."
)
consecutive_errors = 0
while not shutdown_event.is_set():
nc_client = None
try:
@@ -232,21 +259,64 @@ async def user_scanner_task(
nc_client=nc_client,
)
consecutive_errors = 0 # Reset on success
except NotProvisionedError:
logger.warning(
f"[{mode_label}] User {user_id} no longer provisioned, stopping scanner"
)
break
except HTTPStatusError as e:
status_code = e.response.status_code
if status_code in (401, 403):
logger.warning(
f"[{mode_label}] Scanner auth failed for {user_id} "
f"(HTTP {status_code}), stopping scanner. "
f"User may need to re-provision credentials."
)
break
elif status_code == 429:
retry_after = min(int(e.response.headers.get("Retry-After", "60")), 300)
logger.warning(
f"[{mode_label}] Scanner rate-limited for {user_id}, "
f"backing off {retry_after}s"
)
try:
with anyio.move_on_after(retry_after):
await shutdown_event.wait()
# anyio.get_cancelled_exc_class() catches task cancellation
# (e.g. from task group teardown) so we exit cleanly.
except anyio.get_cancelled_exc_class():
break
continue
else:
consecutive_errors += 1
logger.error(
f"[{mode_label}] Scanner HTTP error for {user_id}: {e} "
f"({consecutive_errors}/{max_consecutive_errors})",
exc_info=True,
)
except Exception as e:
consecutive_errors += 1
logger.error(
f"[{mode_label}] Scanner error for {user_id}: {e}", exc_info=True
f"[{mode_label}] Scanner error for {user_id}: {e} "
f"({consecutive_errors}/{max_consecutive_errors})",
exc_info=True,
)
finally:
if nc_client:
await nc_client.close()
if consecutive_errors >= max_consecutive_errors:
logger.error(
f"[{mode_label}] Scanner for {user_id} hit {max_consecutive_errors} "
f"consecutive errors, stopping scanner"
)
break
# Sleep until next interval or wake event
try:
with anyio.move_on_after(settings.vector_sync_scan_interval):
@@ -280,8 +350,6 @@ async def multi_user_processor_task(
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
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
logger.info(f"[{mode_label}] Processor {worker_id} started")
task_status.started()
@@ -419,8 +487,15 @@ async def user_manager_task(
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
+1 -2
View File
@@ -21,6 +21,7 @@ import logging
import time
import uuid
from qdrant_client import models
from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
from nextcloud_mcp_server.config import get_settings
@@ -82,8 +83,6 @@ async def write_placeholder_point(
# Create empty sparse vector for placeholders
# Use models.SparseVector with empty indices/values
from qdrant_client import models
empty_sparse = models.SparseVector(indices=[], values=[])
# Generate deterministic point ID
+3 -6
View File
@@ -17,6 +17,7 @@ from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.document_processors import get_registry
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
from nextcloud_mcp_server.observability.metrics import (
record_qdrant_operation,
@@ -24,7 +25,9 @@ from nextcloud_mcp_server.observability.metrics import (
update_vector_sync_queue_size,
)
from nextcloud_mcp_server.observability.tracing import trace_operation
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
from nextcloud_mcp_server.vector.document_chunker import DocumentChunker
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
from nextcloud_mcp_server.vector.placeholder import delete_placeholder_point
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
from nextcloud_mcp_server.vector.scanner import DocumentTask
@@ -275,8 +278,6 @@ async def _index_document(
content_bytes = None # Notes don't have binary content
content_type = None
elif doc_task.doc_type == "news_item":
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
item = await nc_client.news.get_item(int(doc_task.doc_id))
# Convert HTML body to Markdown for better embedding
body_markdown = html_to_markdown(item.get("body", ""))
@@ -437,8 +438,6 @@ async def _index_document(
},
):
# Use document processor registry to extract text
from nextcloud_mcp_server.document_processors import get_registry
registry = get_registry()
try:
@@ -586,8 +585,6 @@ async def _index_document(
"vector_sync.pdf_size": len(content_bytes),
},
):
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
# Build chunk data for batch processing
# Format: (chunk_index, start_offset, end_offset, page_number, chunk_text)
chunk_data: list[tuple[int, int, int, int | None, str]] = [
+1 -3
View File
@@ -6,6 +6,7 @@ from qdrant_client import AsyncQdrantClient, models
from qdrant_client.models import Distance, VectorParams
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import get_embedding_service
logger = logging.getLogger(__name__)
@@ -62,9 +63,6 @@ async def get_qdrant_client() -> AsyncQdrantClient:
# Get collection name (auto-generated from deployment ID + model)
collection_name = settings.get_collection_name()
# Import here to avoid circular dependency
from nextcloud_mcp_server.embedding import get_embedding_service
embedding_service = get_embedding_service()
# Detect dimension dynamically (for OllamaEmbeddingProvider)
+2 -4
View File
@@ -8,6 +8,7 @@ import os
import random
import time
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
import anyio
from anyio.abc import TaskStatus
@@ -15,6 +16,7 @@ from anyio.streams.memory import MemoryObjectSendStream
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.client.news import NewsItemType
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.observability.metrics import record_vector_sync_scan
from nextcloud_mcp_server.observability.tracing import trace_operation
@@ -418,8 +420,6 @@ async def scan_user_documents(
modified_at = file_info.get("last_modified_timestamp", int(time.time()))
if isinstance(file_info.get("last_modified"), str):
# Parse RFC 2822 date format if needed
from email.utils import parsedate_to_datetime
try:
dt = parsedate_to_datetime(file_info["last_modified"])
modified_at = int(dt.timestamp())
@@ -615,8 +615,6 @@ async def scan_news_items(
Returns:
Number of items queued for processing
"""
from nextcloud_mcp_server.client.news import NewsItemType
settings = get_settings()
queued = 0
+11 -9
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.60.2"
version = "0.64.5"
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"}
@@ -10,10 +10,10 @@ license = {text = "AGPL-3.0-only"}
requires-python = ">=3.11"
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
dependencies = [
"mcp[cli] (>=1.23,<1.24)",
"mcp[cli] (>=1.26,<1.27)",
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
"icalendar (>=6.0.0,<7.0.0)",
"icalendar (>=7.0.2,<7.1.0)",
"pythonvcard4>=0.2.0",
"pydantic>=2.11.4",
"click>=8.1.8",
@@ -98,23 +98,25 @@ version_files = [
# Ignore tags from other components
ignored_tag_formats = [
"nextcloud-mcp-server-*", # Helm chart tags
"astrolabe-v*", # Astrolabe tags
]
# Filter commits by scope (all scopes except helm and astrolabe)
# Filter commits by scope (all scopes except helm)
[tool.commitizen.customize]
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:"
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:\\s.+"
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:"
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:\\s.+"
[tool.ruff.lint]
extend-select = ["I"]
extend-select = ["I", "PLC0415"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["PLC0415"]
[tool.uv.sources]
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]
-90
View File
@@ -1,90 +0,0 @@
#!/bin/bash
# Bump Astrolabe app version
set -euo pipefail
# Parse optional --increment flag
INCREMENT=""
while [[ $# -gt 0 ]]; do
case $1 in
--increment)
INCREMENT="$2"
shift 2
;;
*)
echo "❌ Error: Unknown option: $1" >&2
echo "Usage: $0 [--increment PATCH|MINOR|MAJOR]" >&2
exit 1
;;
esac
done
# Validate dependencies
command -v uv >/dev/null 2>&1 || {
echo "❌ Error: uv not found" >&2
echo " Install from https://docs.astral.sh/uv/" >&2
exit 1
}
# Validate Astrolabe directory exists
if [ ! -d "third_party/astrolabe" ]; then
echo "❌ Error: Must run from repository root (third_party/astrolabe not found)" >&2
exit 1
fi
cd third_party/astrolabe
# Validate required files exist
if [ ! -f "appinfo/info.xml" ]; then
echo "❌ Error: appinfo/info.xml not found" >&2
exit 1
fi
if [ ! -f "package.json" ]; then
echo "❌ Error: package.json not found" >&2
exit 1
fi
echo "Bumping Astrolabe version..."
if [ -n "$INCREMENT" ]; then
echo " Forcing $INCREMENT bump"
fi
# Build commitizen command
CZ_CMD="uv run cz --config .cz.toml bump --yes"
if [ -n "$INCREMENT" ]; then
CZ_CMD="$CZ_CMD --increment $INCREMENT"
fi
# Run commitizen bump and capture output
if ! output=$($CZ_CMD 2>&1); then
cd ../..
# Check if this is the expected "no commits to bump" case
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
echo "️ No commits eligible for version bump" >&2
echo "$output" >&2
exit 0
fi
# Otherwise, this is an actual error
echo "❌ Error: Version bump failed" >&2
echo "$output" >&2
echo "" >&2
echo "Common causes:" >&2
echo " - No commits with scope 'astrolabe' since last version" >&2
echo " - No conventional commits found (use feat(astrolabe):, fix(astrolabe):, etc.)" >&2
echo " - Git working directory not clean" >&2
exit 1
fi
echo "$output"
echo ""
echo "✓ Astrolabe version bumped successfully"
echo " Updated: appinfo/info.xml, package.json"
echo " Tag format: astrolabe-v\${version}"
echo ""
echo "Next steps:"
echo " cd ../.."
echo " git push --follow-tags"
cd ../..
+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())
@@ -273,6 +273,86 @@ async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
raise
async def test_update_event_extended_fields(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes."""
calendar_name = temporary_calendar
tomorrow = datetime.now() + timedelta(days=1)
event_data = {
"title": "Extended Fields Update Test",
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
"description": "Base event for extended-field update test",
}
event_uid = None
try:
result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result["uid"]
logger.info(f"Created base event for extended fields test: {event_uid}")
# --- Phase 1: Set all four extended fields ---
updated_data = {
"categories": "work,meeting",
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
"attendees": "alice@example.com,bob@example.com",
"reminder_minutes": 15,
}
await nc_client.calendar.update_event(calendar_name, event_uid, updated_data)
retrieved, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
# Verify categories
assert "work" in retrieved.get("categories", "")
assert "meeting" in retrieved.get("categories", "")
# Verify recurrence rule
assert retrieved.get("recurring") is True
assert "WEEKLY" in retrieved.get("recurrence_rule", "")
# Verify attendees
attendees = retrieved.get("attendees", "")
assert "alice@example.com" in attendees
assert "bob@example.com" in attendees
logger.info("Phase 1 passed: all extended fields set correctly")
# --- Phase 2: Clear all four extended fields ---
cleared_data = {
"categories": "",
"recurrence_rule": "",
"attendees": "",
"reminder_minutes": 0,
}
await nc_client.calendar.update_event(calendar_name, event_uid, cleared_data)
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
# Verify categories cleared
assert not cleared.get("categories")
# Verify recurrence cleared
assert cleared.get("recurring") is not True
assert not cleared.get("recurrence_rule")
# Verify attendees cleared
assert not cleared.get("attendees")
logger.info("Phase 2 passed: all extended fields cleared correctly")
except Exception as e:
logger.error(f"Extended fields update test failed: {e}")
raise
finally:
if event_uid:
try:
await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception:
pass
async def test_create_event_with_attendees(
nc_client: NextcloudClient, temporary_calendar: str
):
@@ -380,6 +460,177 @@ async def test_event_with_url_and_categories(
raise
async def test_list_events_date_range_filtering(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test that date range filtering actually excludes events outside the range.
Reproduces GH-538: get_calendar_events() accepted date range parameters
but returned events from the entire calendar history, ignoring date filters.
"""
calendar_name = temporary_calendar
past_uid = None
future_uid = None
try:
# Create Event A: 30 days in the past
past_date = datetime.now() - timedelta(days=30)
past_event_data = {
"title": f"Past Event {uuid.uuid4().hex[:8]}",
"start_datetime": past_date.strftime("%Y-%m-%dT10:00:00"),
"end_datetime": past_date.strftime("%Y-%m-%dT11:00:00"),
"description": "Event in the past for date range test",
}
result_past = await nc_client.calendar.create_event(
calendar_name, past_event_data
)
past_uid = result_past["uid"]
logger.info(f"Created past event: {past_uid}")
# Create Event B: 1 day in the future
future_date = datetime.now() + timedelta(days=1)
future_event_data = {
"title": f"Future Event {uuid.uuid4().hex[:8]}",
"start_datetime": future_date.strftime("%Y-%m-%dT14:00:00"),
"end_datetime": future_date.strftime("%Y-%m-%dT15:00:00"),
"description": "Event in the future for date range test",
}
result_future = await nc_client.calendar.create_event(
calendar_name, future_event_data
)
future_uid = result_future["uid"]
logger.info(f"Created future event: {future_uid}")
# Query with date range: today → 7 days ahead
now = datetime.now()
week_ahead = now + timedelta(days=7)
events = await nc_client.calendar.get_calendar_events(
calendar_name=calendar_name,
start_datetime=now,
end_datetime=week_ahead,
limit=50,
)
event_uids = [e["uid"] for e in events]
# Future event (tomorrow) SHOULD be in results
assert future_uid in event_uids, (
f"Future event {future_uid} should be in date-filtered results"
)
# Past event (30 days ago) should NOT be in results
assert past_uid not in event_uids, (
f"Past event {past_uid} should be excluded by date range filter "
f"(GH-538: date range was being ignored)"
)
logger.info(
f"Date range filtering works: {len(events)} events returned, "
f"past event correctly excluded"
)
finally:
# Cleanup both events
for uid in [past_uid, future_uid]:
if uid:
try:
await nc_client.calendar.delete_event(calendar_name, uid)
except Exception as e:
logger.warning(f"Cleanup failed for event {uid}: {e}")
async def test_recurring_event_date_range_expansion(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test that recurring events are expanded into individual occurrences.
When querying with a date range, a recurring event should return one
event dict per occurrence within the range, each with the correct
start_datetime for that occurrence (not the original master event date).
This is a follow-up to GH-538: the time-range filter correctly selected
recurring events, but returned the master event with its original DTSTART
instead of expanding occurrences.
"""
calendar_name = temporary_calendar
event_uid = None
try:
# Create a daily recurring event starting 7 days ago
start = datetime.now() - timedelta(days=7)
event_data = {
"title": f"Daily Recurrence {uuid.uuid4().hex[:8]}",
"start_datetime": start.strftime("%Y-%m-%dT09:00:00"),
"end_datetime": start.strftime("%Y-%m-%dT10:00:00"),
"description": "Daily recurring event for expansion test",
"recurring": True,
"recurrence_rule": "FREQ=DAILY",
}
result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result["uid"]
logger.info(f"Created daily recurring event: {event_uid}")
# Query with date range: today → 3 days ahead
query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
query_end = query_start + timedelta(days=3)
events = await nc_client.calendar.get_calendar_events(
calendar_name=calendar_name,
start_datetime=query_start,
end_datetime=query_end,
limit=50,
)
# Filter to only our recurring event (calendar may have others)
our_events = [e for e in events if e["uid"] == event_uid]
# Should have multiple occurrences (one per day in the range)
assert len(our_events) >= 2, (
f"Expected multiple expanded occurrences, got {len(our_events)}. "
f"Expansion may not be working."
)
# Each occurrence should have a different start_datetime
start_dates = [e["start_datetime"] for e in our_events]
assert len(set(start_dates)) == len(our_events), (
f"Each occurrence should have a unique start_datetime, got: {start_dates}"
)
# No start_datetime should fall outside the queried range
for e in our_events:
event_start = datetime.fromisoformat(e["start_datetime"])
# Remove timezone info for comparison if present
if event_start.tzinfo is not None:
event_start = event_start.replace(tzinfo=None)
assert event_start >= query_start - timedelta(hours=1), (
f"Occurrence {e['start_datetime']} is before query start {query_start}"
)
assert event_start < query_end + timedelta(hours=1), (
f"Occurrence {e['start_datetime']} is after query end {query_end}"
)
# Expanded occurrences should NOT have recurrence rules
# (server strips RRULE when expanding)
for e in our_events:
assert not e.get("recurring"), (
"Expanded occurrence should not have recurring=True, "
"RRULE should be stripped by server-side expansion"
)
logger.info(
f"Recurring event expansion works: {len(our_events)} occurrences "
f"returned with unique start dates"
)
finally:
if event_uid:
try:
await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception as e:
logger.warning(f"Cleanup failed for recurring event {event_uid}: {e}")
async def test_calendar_operations_error_handling(
nc_client: NextcloudClient,
):
+57 -22
View File
@@ -2351,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
@@ -2391,6 +2400,32 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
except Exception as e:
logger.warning(f"Error deleting test user {username}: {e}")
# Clean up all app passwords from MCP server to prevent stale scanners
import subprocess
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"mcp-multi-user-basic",
"sqlite3",
"/app/data/tokens.db",
"DELETE FROM app_passwords;",
],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
logger.warning(
f"Failed to clean up app passwords (rc={result.returncode}): "
f"{result.stderr}"
)
else:
logger.info("Cleaned up all test app passwords")
async def _get_oauth_token_for_user(
browser,
@@ -1,18 +1,21 @@
"""Integration tests for app password provisioning via Astrolabe.
"""Integration tests for app password provisioning via management API.
Tests the complete flow for multi-user BasicAuth mode:
1. User stores app password via Astrolabe API
2. MCP server retrieves it via OAuth client credentials
3. Background sync uses it to access Nextcloud (NOT OAuth refresh tokens)
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 pytest
import tempfile
from pathlib import Path
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.config import get_settings
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError,
get_user_client,
@@ -21,140 +24,60 @@ from nextcloud_mcp_server.vector.oauth_sync import (
)
@pytest.mark.integration
async def test_astrolabe_client_initialization():
"""Test AstrolabeClient can be instantiated."""
client = AstrolabeClient(
nextcloud_host="http://localhost:8080",
client_id="test-client",
client_secret="test-secret",
)
@pytest.fixture
def encryption_key():
"""Generate a test encryption key."""
return Fernet.generate_key().decode()
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
@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_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_uses_local_storage(temp_storage, mocker):
"""Test that BasicAuth mode uses locally stored app passwords.
# 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"])
@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_basic_auth_mode_uses_app_password_only(mocker):
"""Test that BasicAuth mode uses ONLY app passwords, NOT OAuth tokens.
In multi-user BasicAuth mode, OAuth refresh tokens are NOT used.
This is a complete separation of concerns.
In multi-user BasicAuth mode, app passwords are stored locally
in the MCP server's database after being provisioned via the API.
"""
# Mock settings to have client credentials
mock_settings = mocker.MagicMock()
mock_settings.oidc_client_id = "test-client-id"
mock_settings.oidc_client_secret = "test-client-secret"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
return_value=mock_settings,
)
# Store an app password in local storage
await temp_storage.store_app_password("test_user", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
# 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,
)
# Call get_user_client in BasicAuth mode
_client = await get_user_client(
# Call get_user_client_basic_auth with local storage
client = await get_user_client_basic_auth(
user_id="test_user",
token_broker=None, # No token broker needed for BasicAuth mode
nextcloud_host="http://localhost:8080",
use_basic_auth=True,
storage=temp_storage,
)
# Verify app password was requested
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
# Verify client was created successfully with correct username
assert _client is not None
assert _client.username == "test_user"
# Verify client was created with correct credentials
assert client is not None
assert client.username == "test_user"
@pytest.mark.integration
async def test_basic_auth_mode_raises_error_without_app_password(mocker):
async def test_basic_auth_mode_raises_error_without_app_password(temp_storage):
"""Test that BasicAuth mode raises NotProvisionedError if no app password.
There is NO fallback to OAuth - if no app password, user must provision one.
"""
# Mock settings to have client credentials
mock_settings = mocker.MagicMock()
mock_settings.oidc_client_id = "test-client-id"
mock_settings.oidc_client_secret = "test-client-secret"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
return_value=mock_settings,
)
# Don't store any app password
# Mock AstrolabeClient to return None (no app password)
mock_astrolabe = mocker.AsyncMock()
mock_astrolabe.get_user_app_password.return_value = None
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
return_value=mock_astrolabe,
)
# Call get_user_client in BasicAuth mode - should raise NotProvisionedError
# Call get_user_client_basic_auth - should raise NotProvisionedError
with pytest.raises(NotProvisionedError) as exc_info:
await get_user_client(
await get_user_client_basic_auth(
user_id="test_user",
token_broker=None,
nextcloud_host="http://localhost:8080",
use_basic_auth=True,
storage=temp_storage,
)
# Verify error message mentions app password provisioning
@@ -162,6 +85,33 @@ async def test_basic_auth_mode_raises_error_without_app_password(mocker):
assert "test_user" in str(exc_info.value)
@pytest.mark.integration
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.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.
@@ -183,7 +133,7 @@ async def test_oauth_mode_uses_refresh_token_only(mocker):
use_basic_auth=False, # OAuth mode
)
# Verify token broker was called (NOT Astrolabe)
# Verify token broker was called
mock_token_broker.get_background_token.assert_called_once()
@@ -213,38 +163,6 @@ async def test_oauth_mode_raises_error_without_token(mocker):
assert "test_user" in str(exc_info.value)
@pytest.mark.integration
async def test_get_user_client_basic_auth_function(mocker):
"""Test the dedicated get_user_client_basic_auth function."""
# Mock settings to have client credentials
mock_settings = mocker.MagicMock()
mock_settings.oidc_client_id = "test-client-id"
mock_settings.oidc_client_secret = "test-client-secret"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
return_value=mock_settings,
)
# Mock AstrolabeClient
mock_astrolabe = mocker.AsyncMock()
mock_astrolabe.get_user_app_password.return_value = "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
return_value=mock_astrolabe,
)
# Call dedicated function
client = await get_user_client_basic_auth(
user_id="alice",
nextcloud_host="http://localhost:8080",
)
assert client is not None
assert client.username == "alice"
mock_astrolabe.get_user_app_password.assert_called_once_with("alice")
@pytest.mark.integration
async def test_get_user_client_oauth_function(mocker):
"""Test the dedicated get_user_client_oauth function."""
@@ -276,3 +194,69 @@ async def test_oauth_mode_requires_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,
)
@@ -1,5 +1,10 @@
"""Integration test for multi-user Astrolabe background sync enablement.
Cross-system interface test: Tests the MCP server's integration with the
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
This test verifies that multiple users can independently:
1. Log in to Nextcloud
2. Generate an app password in Security settings
@@ -43,8 +48,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)
@@ -75,6 +91,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:
@@ -105,16 +404,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
@@ -172,11 +487,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
@@ -226,9 +541,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}")
@@ -236,6 +551,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")
@@ -319,21 +646,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.
@@ -408,6 +834,70 @@ async def verify_app_password_created(username: str) -> bool:
return False
def clear_stale_test_state(clear_preferences: bool = False) -> None:
"""Clear stale app passwords, bruteforce entries, and optionally Astrolabe preferences."""
commands: list[tuple[list[str], str]] = [
(
[
"docker",
"compose",
"exec",
"-T",
"mcp-multi-user-basic",
"sqlite3",
"/app/data/tokens.db",
"DELETE FROM app_passwords;",
],
"app passwords",
),
(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-e",
"DELETE FROM oc_bruteforce_attempts;",
],
"bruteforce entries",
),
]
if clear_preferences:
commands.append(
(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-e",
"DELETE FROM oc_preferences WHERE appid = 'astrolabe';",
],
"Astrolabe preferences",
),
)
for cmd, label in commands:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
logger.warning(
f"Failed to clear {label} (rc={result.returncode}): {result.stderr}"
)
else:
logger.debug(f"Cleared {label}")
@pytest.mark.integration
@pytest.mark.oauth
async def test_multi_user_astrolabe_background_sync_enablement(
@@ -435,6 +925,10 @@ async def test_multi_user_astrolabe_background_sync_enablement(
This tests ADR-002 Tier 2 authentication: User-specific app passwords for background operations
in multi-user BasicAuth deployments.
"""
# Clear stale state from previous test runs
logger.info("Clearing stale app passwords and bruteforce entries...")
clear_stale_test_state()
# 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(
@@ -772,6 +1266,12 @@ async def test_revoke_background_sync_access(
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).
"""
# Clear stale state from previous test runs
logger.info(
"Clearing stale app passwords, bruteforce entries, and Astrolabe preferences..."
)
clear_stale_test_state(clear_preferences=True)
# 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(
@@ -792,9 +1292,14 @@ async def test_revoke_background_sync_access(
# 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 2: Complete full authorization (OAuth Step 1 + App Password Step 2)
auth_result = await complete_astrolabe_authorization(page, username, password)
assert auth_result["step1"], (
f"OAuth authorization (Step 1) failed for {username}"
)
assert auth_result["step2"], (
f"App password setup (Step 2) failed for {username}"
)
# Step 3: Verify background sync is enabled
assert await verify_app_password_created(username), (
@@ -0,0 +1,376 @@
"""Integration test for Astrolabe Plotly 3D visualization with multi-user BasicAuth mode.
Cross-system interface test: Tests the MCP server's integration with the
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
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()
@@ -1,5 +1,10 @@
"""Integration tests for Astrolabe personal settings page buttons.
Cross-system interface test: Tests the MCP server's integration with the
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
Tests the button functionality on /settings/user/astrolabe:
1. Disable Indexing button (POST to /apps/astrolabe/api/revoke)
2. Disconnect button (POST to /apps/astrolabe/oauth/disconnect)
@@ -0,0 +1,700 @@
"""Integration tests for Astrolabe token refresh flow.
Cross-system interface test: Tests the MCP server's integration with the
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
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}")
@@ -1,5 +1,10 @@
"""Test Astrolabe integration with multiple MCP server deployments.
Cross-system interface test: Tests the MCP server's integration with the
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
This test suite verifies that the Astrolabe app can be dynamically configured
to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.).
@@ -78,11 +78,10 @@ async def test_deck_board_view_permissions(
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
logger.info(f"Bob can see {len(response_data)} boards: {board_ids}")
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Bob can see {len(board_list)} boards: {board_ids}")
# Bob should see the shared board
if board_id in board_ids:
@@ -98,11 +97,10 @@ async def test_deck_board_view_permissions(
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
logger.info(f"Diana can see {len(response_data)} boards")
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Diana can see {len(board_list)} boards")
# Diana should NOT see the board
assert board_id not in board_ids, "Diana should not see board without ACL"
@@ -313,10 +311,9 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Alice can see boards: {board_ids}")
# Alice should NOT see Bob's board
@@ -332,10 +329,9 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Bob can see boards: {board_ids}")
# Bob should NOT see Alice's board

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