Compare commits

...

117 Commits

Author SHA1 Message Date
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
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
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
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] 119a422a35 chore(deps): update actions/checkout digest to de0fac2 2026-02-04 11:08:12 +00:00
63 changed files with 2416 additions and 481 deletions
+2 -2
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'
+2 -2
View File
@@ -27,13 +27,13 @@ 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@ea36d6abdedc17fc2a671b36060770b208a6f8f1 # v1.0.51
uses: anthropics/claude-code-action@ade221fd1c400376a4799977d683a4eda09f9d7c # v1.0.60
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: "renovate-bot-cbcoutinho"
+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@ea36d6abdedc17fc2a671b36060770b208a6f8f1 # v1.0.51
uses: anthropics/claude-code-action@ade221fd1c400376a4799977d683a4eda09f9d7c # v1.0.60
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
+2 -2
View File
@@ -24,7 +24,7 @@ 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@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
@@ -97,7 +97,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: rag-evaluation-results
path: |
+1 -1
View File
@@ -18,7 +18,7 @@ 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@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Install Python 3.11
+3 -3
View File
@@ -9,7 +9,7 @@ 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@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Check format
@@ -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
+33
View File
@@ -5,6 +5,39 @@ 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.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
+2 -2
View File
@@ -1,6 +1,6 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
FROM docker.io/library/python:3.12-slim-trixie@sha256:39e4e1ccb01578e3c86f7a0cf7b7fd89b8dbe2c27a88de11cf726ba669469f49
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.10.6@sha256:2f2ccd27bbf953ec7a9e3153a4563705e41c852a5e1912b438fc44d88d6cb52c /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:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
FROM docker.io/library/python:3.12-slim-trixie@sha256:39e4e1ccb01578e3c86f7a0cf7b7fd89b8dbe2c27a88de11cf726ba669469f49
WORKDIR /app
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.10.6@sha256:2f2ccd27bbf953ec7a9e3153a4563705e41c852a5e1912b438fc44d88d6cb52c /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.57.50"
version = "0.57.85"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
+100
View File
@@ -14,6 +14,106 @@ 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.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)
+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.43.0
digest: sha256:533eda3fdb4bd92cdafee49bd7b0428fc87d21b509032442c04ed645900e464a
generated: "2026-02-16T11:16:41.257136832Z"
version: 1.45.0
digest: sha256:a325b7093a64921fb5c6648c19c31a61799c8b279da21f08b9e892a9e5a37227
generated: "2026-02-23T05:14:08.147145912Z"
+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.57.50
appVersion: "0.64.0"
version: 0.57.85
appVersion: "0.64.4"
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.43.0"
version: "1.45.0"
repository: https://otwld.github.io/ollama-helm
condition: ollama.enabled
+6 -6
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:345fa26d595e8c7fe298e0c4098ed400356f502458769c8902229b3437d6da2b
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:0804c395e634e624243387d3c3a9c45fcaca876d313c2c8b52c3fdf9a912dded
image: docker.io/library/redis:alpine@sha256:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0
restart: always
app:
image: docker.io/library/nextcloud:32.0.6@sha256:0e1084cc59df77bec7d6bb29d9ac6939da8372512237a9c51f74ff0a970524f2
image: docker.io/library/nextcloud:32.0.6@sha256:dcf9c6019d05df721bb7bada99748964c95446ea479771e9073ceaded733407e
restart: always
ports:
- 127.0.0.1:8080:80
@@ -60,7 +60,7 @@ services:
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
unstructured:
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:3b9280eb9aa53d76a8f4a2465400ae747774d4bfd71dd73d603353b0b55c435d
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:ba6cb073af079c498e9466a5a9152ba4b6c9cad12efeeaf053ba383023d5db08
restart: always
ports:
- 127.0.0.1:8002:8000
@@ -207,7 +207,7 @@ services:
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
command:
- "start-dev"
- "--import-realm"
@@ -294,7 +294,7 @@ services:
- smithery
qdrant:
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
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
+6 -13
View File
@@ -20,9 +20,15 @@ import time
from importlib.metadata import version
from typing import Any
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__)
@@ -196,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
@@ -266,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(
@@ -299,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
@@ -375,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
+5 -13
View File
@@ -15,18 +15,16 @@ import logging
import re
import time
from collections import defaultdict
from typing import TYPE_CHECKING
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse
from ..http import nextcloud_httpx_client
if TYPE_CHECKING:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
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__)
@@ -158,7 +156,7 @@ def _extract_basic_auth(
return username, password, None
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
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.
@@ -170,8 +168,6 @@ async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
Returns:
Initialized RefreshTokenStorage instance
"""
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = getattr(request.app.state, "storage", None)
if not storage:
@@ -202,8 +198,6 @@ async def provision_app_password(request: Request) -> JSONResponse:
- Only the user who owns the password can provision it
- Rate limited to prevent brute-force attacks
"""
from nextcloud_mcp_server.config import get_settings
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
@@ -364,8 +358,6 @@ async def delete_app_password(request: Request) -> JSONResponse:
Requires BasicAuth with the user's credentials.
"""
from nextcloud_mcp_server.config import get_settings
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
+13 -50
View File
@@ -11,13 +11,10 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
import base64
import logging
from typing import TYPE_CHECKING, Any
from typing import Any
import pymupdf
if TYPE_CHECKING:
pass
from qdrant_client.models import FieldCondition, Filter, MatchValue
from starlette.requests import Request
from starlette.responses import JSONResponse
@@ -29,6 +26,17 @@ from nextcloud_mcp_server.api.management import (
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__)
@@ -68,8 +76,6 @@ async def unified_search(request: Request) -> JSONResponse:
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(
@@ -144,12 +150,6 @@ async def unified_search(request: Request) -> JSONResponse:
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)
@@ -254,17 +254,9 @@ async def unified_search(request: Request) -> JSONResponse:
# 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)
@@ -305,8 +297,6 @@ async def vector_search(request: Request) -> JSONResponse:
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(
@@ -354,12 +344,6 @@ async def vector_search(request: Request) -> JSONResponse:
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)
@@ -428,18 +412,10 @@ async def vector_search(request: Request) -> JSONResponse:
# 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)
@@ -549,9 +525,6 @@ async def get_chunk_context(request: Request) -> JSONResponse:
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:
@@ -581,14 +554,6 @@ async def get_chunk_context(request: Request) -> JSONResponse:
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()
@@ -735,8 +700,6 @@ async def get_pdf_preview(request: Request) -> JSONResponse:
raise ValueError("Nextcloud host not configured")
# Download PDF via WebDAV using user's token
from nextcloud_mcp_server.client import NextcloudClient
async with NextcloudClient.from_token(
base_url=nextcloud_host, token=token, username=user_id
) as nc_client:
+1 -6
View File
@@ -18,6 +18,7 @@ from nextcloud_mcp_server.api.management import (
extract_bearer_token,
validate_token_and_get_user,
)
from nextcloud_mcp_server.client.webhooks import WebhooksClient
from ..http import nextcloud_httpx_client
@@ -115,8 +116,6 @@ async def list_webhooks(request: Request) -> JSONResponse:
)
try:
from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
@@ -180,8 +179,6 @@ async def create_webhook(request: Request) -> JSONResponse:
)
try:
from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Parse request body
body = await request.json()
event = body.get("event")
@@ -256,8 +253,6 @@ async def delete_webhook(request: Request) -> JSONResponse:
)
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:
+73 -91
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,
@@ -82,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()
@@ -106,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,
)
@@ -127,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,
)
@@ -145,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,
)
@@ -165,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,
)
@@ -431,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)
@@ -507,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()
@@ -559,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()
@@ -625,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)")
@@ -756,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:
@@ -881,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)
@@ -1077,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(
@@ -1544,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")
@@ -1653,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
@@ -1745,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:
@@ -1815,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
@@ -1826,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
@@ -2113,24 +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 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,
)
routes.append(Route("/api/v1/status", get_server_status, methods=["GET"]))
routes.append(
Route(
@@ -2238,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.
@@ -2301,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}"
@@ -2335,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)")
@@ -2344,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")
)
@@ -2372,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 = [
@@ -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
@@ -153,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)
+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 -11
View File
@@ -26,11 +26,13 @@ import secrets
import time
from base64 import urlsafe_b64encode
from urllib.parse import urlencode
from urllib.parse import urlparse as parse_url
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
@@ -228,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
@@ -364,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)
@@ -567,8 +565,6 @@ async def oauth_callback_nextcloud(request: Request):
</html>
"""
from starlette.responses import HTMLResponse
return HTMLResponse(content=success_html, status_code=200)
@@ -633,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
+67 -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
@@ -1414,6 +1414,69 @@ class RefreshTokenStorage:
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:
"""
+5 -7
View File
@@ -13,11 +13,13 @@ import traceback
from pathlib import Path
from typing import Any
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
@@ -56,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
@@ -129,7 +129,9 @@ 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.vector.qdrant_client import get_qdrant_client
from nextcloud_mcp_server.vector.qdrant_client import ( # noqa: PLC0415
get_qdrant_client,
)
qdrant_client = await get_qdrant_client()
@@ -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
+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
+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)
+1 -1
View File
@@ -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))
+4 -19
View File
@@ -6,12 +6,14 @@ 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
@@ -103,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>
@@ -301,10 +301,6 @@ class CalendarClient:
end_datetime: Optional[dt.datetime] = None,
) -> list:
"""Execute a CalDAV REPORT with time-range filter."""
from caldav.async_collection import AsyncEvent
from caldav.elements import cdav, dav
from lxml import etree # type: ignore[import-untyped]
# Ensure naive datetimes are treated as UTC
if start_datetime and start_datetime.tzinfo is None:
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
@@ -889,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)
@@ -955,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))
@@ -1061,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"]
+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)
+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())
+1 -3
View File
@@ -268,9 +268,7 @@ class Settings:
"This is insecure and should only be used for development/testing."
)
if self.nextcloud_ca_bundle:
import os as _os
if not _os.path.isfile(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}"
)
+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()
+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",
+65 -4
View File
@@ -1,15 +1,57 @@
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 _raw_contact_to_model(raw: dict) -> Contact:
"""Convert a raw contact dict from the contacts client to a Contact model.
Only maps fields the client's list_contacts() currently returns:
fullname, nickname, birthday, and email. Additional Contact model fields
(phones, addresses, organization, etc.) require expanding the client's
vCard parsing in ContactsClient.list_contacts().
"""
contact_info = raw.get("contact", {})
# Convert email field (str, list, or None) to list[ContactField]
raw_email = contact_info.get("email")
emails: list[ContactField] = []
if isinstance(raw_email, list):
emails = [ContactField(type="email", value=e) for e in raw_email if e]
elif isinstance(raw_email, str) and raw_email:
emails = [ContactField(type="email", value=raw_email)]
# 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,
custom_fields=custom_fields,
)
def configure_contacts_tools(mcp: FastMCP):
# Contacts tools
@mcp.tool(
@@ -18,10 +60,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 +84,16 @@ def configure_contacts_tools(mcp: FastMCP):
)
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
async def nc_contacts_list_contacts(
ctx: Context, *, addressbook: str
) -> ListContactsResponse:
"""List all contacts in the specified addressbook."""
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",
+15 -11
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",
+3 -9
View File
@@ -8,6 +8,7 @@ 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
@@ -19,9 +20,11 @@ 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
@@ -157,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)
@@ -305,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(
@@ -490,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 (
+6 -15
View File
@@ -7,15 +7,19 @@ 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,
@@ -28,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__)
@@ -82,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
@@ -373,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,8 +660,6 @@ def configure_semantic_tools(mcp: FastMCP):
"""
# Check if vector sync is enabled (supports both old and new env var names)
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if not settings.vector_sync_enabled:
return VectorSyncStatusResponse(
@@ -694,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")
+79 -7
View File
@@ -31,14 +31,15 @@ from anyio.streams.memory import (
MemoryObjectReceiveStream,
MemoryObjectSendStream,
)
from httpx import BasicAuth
from httpx import BasicAuth, HTTPStatusError
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__)
@@ -89,8 +90,6 @@ async def get_user_client_basic_auth(
Raises:
NotProvisionedError: If user has not provisioned an app password
"""
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
# Get or create storage instance
if storage is None:
storage = RefreshTokenStorage.from_env()
@@ -210,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:
@@ -228,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):
@@ -276,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()
+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
+7 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.64.0"
version = "0.64.4"
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",
@@ -106,7 +106,10 @@ changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:
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" }
+26
View File
@@ -2400,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,
@@ -834,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(
@@ -861,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(
@@ -1198,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(
@@ -1218,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), (
@@ -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
+15 -21
View File
@@ -683,17 +683,15 @@ async def test_mcp_calendar_workflow(
f"MCP list events failed: {list_result.content}"
)
events_data = json.loads(list_result.content[0].text)
events_response = json.loads(list_result.content[0].text)
# Debug output to understand what nc_calendar_list_events returns
logger.info(f"list_events result type: {type(events_data)}")
logger.info(f"list_events result content: {events_data}")
# Handle single event returned as dict instead of list (same fix as calendars)
if isinstance(events_data, dict):
# Single event returned as dict instead of list
events_data = [events_data]
logger.info(f"list_events result type: {type(events_response)}")
logger.info(f"list_events result content: {events_response}")
# Response is now a ListEventsResponse with an "events" field
assert isinstance(events_response, dict), "Expected response dict"
events_data = events_response.get("events", [])
assert isinstance(events_data, list), "Expected events list"
# Our created event should be in the list
@@ -706,7 +704,7 @@ async def test_mcp_calendar_workflow(
assert found_event is not None, (
f"Created event {event_uid} not found in events list"
)
assert found_event["title"] == test_event_title
assert found_event["summary"] == test_event_title
# 6. Test list events across all calendars
logger.info("Testing nc_calendar_list_events across all calendars")
@@ -727,13 +725,11 @@ async def test_mcp_calendar_workflow(
f"MCP list all events failed: {all_list_result.content}"
)
all_events_data = json.loads(all_list_result.content[0].text)
# Handle single event returned as dict instead of list (same fix as calendars)
if isinstance(all_events_data, dict):
# Single event returned as dict instead of list
all_events_data = [all_events_data]
all_events_response = json.loads(all_list_result.content[0].text)
# Response is now a ListEventsResponse with an "events" field
assert isinstance(all_events_response, dict), "Expected response dict"
all_events_data = all_events_response.get("events", [])
assert isinstance(all_events_data, list), "Expected events list"
# Our event should still be found when searching all calendars
@@ -780,13 +776,11 @@ async def test_mcp_calendar_workflow(
f"MCP upcoming events failed: {upcoming_result.content}"
)
upcoming_events = json.loads(upcoming_result.content[0].text)
# Handle single event returned as dict instead of list (same fix as other tools)
if isinstance(upcoming_events, dict):
# Single event returned as dict instead of list
upcoming_events = [upcoming_events]
upcoming_response = json.loads(upcoming_result.content[0].text)
# Response is now an UpcomingEventsResponse with an "events" field
assert isinstance(upcoming_response, dict), "Expected response dict"
upcoming_events = upcoming_response.get("events", [])
assert isinstance(upcoming_events, list), "Expected upcoming events list"
# 10. Delete event via MCP
+221
View File
@@ -2,6 +2,10 @@
import pytest
from nextcloud_mcp_server.models.contacts import (
Contact,
ListContactsResponse,
)
from nextcloud_mcp_server.models.notes import (
CreateNoteResponse,
Note,
@@ -12,6 +16,8 @@ from nextcloud_mcp_server.models.semantic import (
SamplingSearchResponse,
SemanticSearchResult,
)
from nextcloud_mcp_server.server.calendar import _event_dict_to_summary
from nextcloud_mcp_server.server.contacts import _raw_contact_to_model
@pytest.mark.unit
@@ -267,3 +273,218 @@ def test_sampling_search_response_serialization():
assert data["model_used"] == "claude-3-5-sonnet"
assert data["stop_reason"] == "maxTokens"
assert data["success"] is True
def _map_contact(raw: dict) -> Contact:
"""Thin wrapper around the production mapping function for test readability."""
return _raw_contact_to_model(raw)
@pytest.mark.unit
def test_contact_mapping_preserves_email_birthday_nickname():
"""Test that list_contacts mapping preserves email, birthday, and nickname.
Regression test for PR #574: the original mapping only kept uid, fn, etag
and silently dropped email, birthday, and nickname.
"""
raw_contact = {
"vcard_id": "abc-123",
"getetag": '"etag-val"',
"contact": {
"fullname": "Jane Doe",
"email": "jane@example.com",
"birthday": "1990-05-15",
"nickname": "JD",
},
}
contact = _map_contact(raw_contact)
assert contact.uid == "abc-123"
assert contact.fn == "Jane Doe"
assert contact.etag == '"etag-val"'
assert contact.birthday == "1990-05-15"
assert len(contact.emails) == 1
assert contact.emails[0].value == "jane@example.com"
assert contact.emails[0].type == "email"
assert contact.custom_fields["nickname"] == "JD"
@pytest.mark.unit
def test_contact_mapping_multiple_emails():
"""Test that multiple emails are mapped correctly."""
raw_contact = {
"vcard_id": "def-456",
"contact": {
"fullname": "John Smith",
"email": ["john@work.com", "john@home.com"],
},
}
contact = _map_contact(raw_contact)
assert len(contact.emails) == 2
assert contact.emails[0].value == "john@work.com"
assert contact.emails[1].value == "john@home.com"
@pytest.mark.unit
def test_contact_mapping_missing_optional_fields():
"""Test mapping when email, birthday, and nickname are absent."""
raw_contact = {
"vcard_id": "ghi-789",
"contact": {"fullname": "No Details"},
}
contact = _map_contact(raw_contact)
assert contact.uid == "ghi-789"
assert contact.fn == "No Details"
assert contact.birthday is None
assert contact.emails == []
assert contact.custom_fields == {}
@pytest.mark.unit
def test_list_contacts_response_wraps_contacts():
"""Test ListContactsResponse wraps contacts correctly for MCP output."""
contacts = [
_map_contact(
{
"vcard_id": "a",
"getetag": '"e1"',
"contact": {
"fullname": "Alice",
"email": "alice@test.com",
"birthday": "2000-01-01",
"nickname": "Ali",
},
}
),
]
response = ListContactsResponse(
contacts=contacts, addressbook="personal", total_count=1
)
data = response.model_dump()
assert data["total_count"] == 1
assert len(data["contacts"]) == 1
c = data["contacts"][0]
assert c["birthday"] == "2000-01-01"
assert c["emails"][0]["value"] == "alice@test.com"
assert c["custom_fields"]["nickname"] == "Ali"
# ============= _event_dict_to_summary tests =============
@pytest.mark.unit
def test_event_dict_to_summary_basic():
"""Test basic mapping with all fields populated."""
event = {
"uid": "evt-001",
"title": "Team Standup",
"start_datetime": "2025-07-28T09:00:00",
"end_datetime": "2025-07-28T09:30:00",
"all_day": False,
"location": "Room 42",
"description": "Daily sync",
"categories": ["work", "meeting"],
"status": "CONFIRMED",
"calendar_name": "office",
"calendar_display_name": "Office Calendar",
}
summary = _event_dict_to_summary(event)
assert summary.uid == "evt-001"
assert summary.summary == "Team Standup"
assert summary.start == "2025-07-28T09:00:00"
assert summary.end == "2025-07-28T09:30:00"
assert summary.all_day is False
assert summary.location == "Room 42"
assert summary.description == "Daily sync"
assert summary.categories == ["work", "meeting"]
assert summary.status == "CONFIRMED"
assert summary.calendar_name == "office"
assert summary.calendar_display_name == "Office Calendar"
@pytest.mark.unit
def test_event_dict_to_summary_categories_string():
"""Test that comma-separated category string is split into a list."""
event = {
"uid": "evt-002",
"title": "Review",
"categories": "work, meeting, important",
}
summary = _event_dict_to_summary(event)
assert summary.categories == ["work", "meeting", "important"]
@pytest.mark.unit
def test_event_dict_to_summary_categories_list_passthrough():
"""Test that a list of categories passes through unchanged."""
event = {
"uid": "evt-003",
"title": "Review",
"categories": ["personal", "health"],
}
summary = _event_dict_to_summary(event)
assert summary.categories == ["personal", "health"]
@pytest.mark.unit
def test_event_dict_to_summary_falsy_location_description():
"""Test that empty/falsy location and description are coerced to None."""
event = {
"uid": "evt-004",
"title": "Quick Chat",
"location": "",
"description": "",
}
summary = _event_dict_to_summary(event)
assert summary.location is None
assert summary.description is None
@pytest.mark.unit
def test_event_dict_to_summary_missing_optional_fields():
"""Test mapping with only required fields present."""
event = {"uid": "evt-005", "title": "Minimal Event"}
summary = _event_dict_to_summary(event)
assert summary.uid == "evt-005"
assert summary.summary == "Minimal Event"
assert summary.start == ""
assert summary.end is None
assert summary.all_day is False
assert summary.location is None
assert summary.description is None
assert summary.categories == []
assert summary.status is None
assert summary.calendar_name is None
assert summary.calendar_display_name is None
@pytest.mark.unit
def test_event_dict_to_summary_calendar_name_without_display_name():
"""Test single-calendar path: calendar_name set, display_name absent falls back."""
event = {
"uid": "evt-006",
"title": "Personal Errand",
"calendar_name": "personal",
}
summary = _event_dict_to_summary(event)
assert summary.calendar_name == "personal"
assert summary.calendar_display_name == "personal"
Generated
+16 -15
View File
@@ -1166,15 +1166,16 @@ wheels = [
[[package]]
name = "icalendar"
version = "6.3.2"
version = "7.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422, upload-time = "2025-11-05T12:49:32.286Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/51/43458f01e229763b05dd937b5e9d41ef506b6eb8b4bf939f8ea34350b853/icalendar-7.0.2.tar.gz", hash = "sha256:de844ff5cde32f539bea7644e36d8494032a926b933bedb92621f2f239760806", size = 440039, upload-time = "2026-02-24T16:13:42.887Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403, upload-time = "2025-11-05T12:49:30.691Z" },
{ url = "https://files.pythonhosted.org/packages/62/ab/e0d44b1de0beb703bbc507ca064300b34046f9f9628f052d1a97ffa61b95/icalendar-7.0.2-py3-none-any.whl", hash = "sha256:ad31a5825b39522a30b073c6ced3ffcdf6c02cbb7dab69ba2e4de32ddbf77cc9", size = 437913, upload-time = "2026-02-24T16:13:40.631Z" },
]
[[package]]
@@ -1697,7 +1698,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.23.2"
version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1715,9 +1716,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/39/a9/0e95530946408747ae200e86553ceda0dbd851d4ae9bbe0d02a69cbd6ad5/mcp-1.23.2.tar.gz", hash = "sha256:df4e4b7273dca2aaf428f9cf7a25bbac0c9007528a65004854b246aef3d157bc", size = 599953, upload-time = "2025-12-08T15:51:02.432Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/6a/1a726905cf41a69d00989e8dfd9de7bd9b4a9f3c8723dac3077b0ba1a7b9/mcp-1.23.2-py3-none-any.whl", hash = "sha256:d8e4c6af0317ad954ea0a53dfb5e229dddea2d0a54568c080e82e8fae4a8264e", size = 231897, upload-time = "2025-12-08T15:51:01.023Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[package.optional-dependencies]
@@ -1988,7 +1989,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.64.0"
version = "0.64.4"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -2051,11 +2052,11 @@ requires-dist = [
{ name = "click", specifier = ">=8.1.8" },
{ name = "fastembed", specifier = ">=0.7.3" },
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
{ name = "icalendar", specifier = ">=6.0.0,<7.0.0" },
{ name = "icalendar", specifier = ">=7.0.2,<7.1.0" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
{ name = "markdownify", specifier = ">=0.14.1" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.23,<1.24" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.26,<1.27" },
{ name = "openai", specifier = ">=2.8.1" },
{ name = "opentelemetry-api", specifier = ">=1.28.2" },
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.28.2" },
@@ -3383,7 +3384,7 @@ wheels = [
[[package]]
name = "recurring-ical-events"
version = "3.8.0"
version = "3.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "icalendar" },
@@ -3391,9 +3392,9 @@ dependencies = [
{ name = "tzdata" },
{ name = "x-wr-timezone" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/90/05dfcc02ecf58bd170305c88db9e3e3933aa73a1f8abac2b326c7fdc1a98/recurring_ical_events-3.8.0.tar.gz", hash = "sha256:3e8c7c35d9bd8956a7ab91afad51477c60d972e1236d3fd1b55087a66bce7d04", size = 602665, upload-time = "2025-06-10T13:23:50.662Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/d4/51c9361bb0efb2290dfd850c036b49acb502794e0fe9cc3520dbf60fd7db/recurring_ical_events-3.8.1.tar.gz", hash = "sha256:c3eb2490a00559fb963d2bdee39acf2f287c91c07dcea4ce80ade1c60a8c3acf", size = 603730, upload-time = "2026-02-18T11:45:53.272Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/25/88a4218cccae06ce6b15e41d2f263dd4a73e8e8cbe41537cd7784a17479b/recurring_ical_events-3.8.0-py3-none-any.whl", hash = "sha256:cf958eb17c92d4dca5c621e44c2b3fffd4ba700dca0db66287c5dc11438f63ba", size = 238228, upload-time = "2025-06-10T13:23:49.048Z" },
{ url = "https://files.pythonhosted.org/packages/14/67/4d4aead359164de68d30ee67efcdbe3784063cb21535c85b9a9a03dd2ebb/recurring_ical_events-3.8.1-py3-none-any.whl", hash = "sha256:3bb3aaa0c87a4d3ab5951360480686bd69f1512945f478be6a2c0f141da0bf78", size = 238286, upload-time = "2026-02-18T11:45:51.631Z" },
]
[[package]]
@@ -3954,11 +3955,11 @@ wheels = [
[[package]]
name = "tzdata"
version = "2025.2"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]